From ac59c27d63f8d81b41b219ef5474a08722f44ecf Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Mon, 24 Aug 2026 23:21:03 +1000 Subject: [PATCH 1/5] fix(project-brain): resolve bare wikilink targets to their real path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit convert-to-okf.ps1 assumed a bare-filename [[wikilink]] target (no directory segment — Obsidian-style same-vault resolution) was already bundle-root-relative, emitting /basename.md regardless of where the file actually lives. Found while implementing #199 (batch B): its source used bare targets like [[T9a-ado-prerequisites]], which resolved to the wrong root-level path instead of /tickets/T9a-ado-prerequisites.md. Fixed by indexing every .md file's basename under the repo root once per invocation and resolving a bare target against it (same-directory preferred on an ambiguous match, else the lexicographically-first match with a warning); a target already containing a path segment, or with no on-disk match at all, keeps the prior literal behavior unchanged. 2 new tests; all 44 existing tests still pass. --- .../project-brain/scripts/convert-to-okf.ps1 | 74 +++++++++++++++++-- tests/convert-to-okf.Tests.ps1 | 23 ++++++ 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 index 54e1ecd1..f6779319 100644 --- a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 +++ b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 @@ -60,8 +60,59 @@ param( $ErrorActionPreference = 'Stop' +function Get-WikilinkIndex { + param([string] $RepoRoot) + + # A bare-filename wikilink target (no '/' in it, Obsidian-style same-vault resolution) + # carries no directory information — resolving it as if it were already bundle-root- + # relative (the old behavior) silently produces a wrong link whenever the target file + # actually lives in a subdirectory. Build a basename -> real-path index once per repo so + # such targets resolve to where the file genuinely is. + if (-not $script:WikilinkIndexCache) { $script:WikilinkIndexCache = @{} } + if ($script:WikilinkIndexCache.ContainsKey($RepoRoot)) { + return $script:WikilinkIndexCache[$RepoRoot] + } + + $index = @{} + $mdFiles = Get-ChildItem -LiteralPath $RepoRoot -Filter '*.md' -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -notmatch '[\\/]\.git[\\/]' } + foreach ($f in $mdFiles) { + $rel = ([IO.Path]::GetRelativePath($RepoRoot, $f.FullName)) -replace '\\', '/' + $base = ([IO.Path]::GetFileNameWithoutExtension($f.Name)).ToLowerInvariant() + if (-not $index.ContainsKey($base)) { + $index[$base] = [System.Collections.Generic.List[string]]::new() + } + $index[$base].Add($rel) + } + $script:WikilinkIndexCache[$RepoRoot] = $index + return $index +} + +function Resolve-BareWikilinkTarget { + param([string] $Target, [string] $RepoRoot, [string] $CurrentDir) + + $index = Get-WikilinkIndex -RepoRoot $RepoRoot + $key = $Target.ToLowerInvariant() + if (-not $index.ContainsKey($key)) { return $null } + + $found = $index[$key] + if ($found.Count -eq 1) { return $found[0] } + + $sameDir = $found | Where-Object { + $dir = if ($_ -match '^(.*)/[^/]+$') { $Matches[1] } else { '' } + $dir -eq $CurrentDir + } + if ($sameDir) { + Write-Warning "convert-to-okf.ps1: ambiguous wikilink target '$Target' ($($found.Count) matches) — resolved to same-directory match '$($sameDir[0])'." + return $sameDir[0] + } + $picked = ($found | Sort-Object)[0] + Write-Warning "convert-to-okf.ps1: ambiguous wikilink target '$Target' ($($found.Count) matches: $($found -join ', ')) — defaulted to '$picked'; verify manually." + return $picked +} + function Format-WikilinkTarget { - param([string] $Target) + param([string] $Target, [string] $RepoRoot, [string] $CurrentDir) # Split off a #fragment before appending .md, so the anchor doesn't get folded into # the filename, then re-append the fragment after. Split at the FIRST '#' so a nested @@ -95,16 +146,21 @@ function Format-WikilinkTarget { # — leave its extension as-is instead of appending a wrong ".md" suffix. return "/$Target$fragment" } + if ($RepoRoot -and $Target -notmatch '/') { + # Bare filename, no path segment — do not assume bundle-root, look it up. + $resolved = Resolve-BareWikilinkTarget -Target $Target -RepoRoot $RepoRoot -CurrentDir $CurrentDir + if ($resolved) { return "/$resolved$fragment" } + } return "/$Target.md$fragment" } function Convert-WikilinksInSegment { - param([string] $Text) + param([string] $Text, [string] $RepoRoot, [string] $CurrentDir) # [[a/b|label]] -> [label](/a/b.md) — must run before the bare-link pattern. $Text = [regex]::Replace($Text, '\[\[([^\]\|]+)\|([^\]]+)\]\]', { param($m) - "[$($m.Groups[2].Value)]($(Format-WikilinkTarget $m.Groups[1].Value))" + "[$($m.Groups[2].Value)]($(Format-WikilinkTarget $m.Groups[1].Value $RepoRoot $CurrentDir))" }) # [[a/b]] -> [b](/a/b.md) @@ -120,14 +176,14 @@ function Convert-WikilinksInSegment { } else { '' } - "[$label]($(Format-WikilinkTarget $rawTarget))" + "[$label]($(Format-WikilinkTarget $rawTarget $RepoRoot $CurrentDir))" }) return $Text } function Convert-Wikilinks { - param([string] $Text) + param([string] $Text, [string] $RepoRoot, [string] $CurrentDir) # Skip fenced code blocks (``` or ~~~ fences, 3+ delimiters) and inline code spans # (backtick runs of 1+) — wikilink-looking text quoted in a code sample or in prose @@ -147,11 +203,11 @@ function Convert-Wikilinks { $lastIndex = 0 foreach ($m in [regex]::Matches($Text, $pattern)) { $prose = $Text.Substring($lastIndex, $m.Index - $lastIndex) - [void]$sb.Append((Convert-WikilinksInSegment -Text $prose)) + [void]$sb.Append((Convert-WikilinksInSegment -Text $prose -RepoRoot $RepoRoot -CurrentDir $CurrentDir)) [void]$sb.Append($m.Value) $lastIndex = $m.Index + $m.Length } - [void]$sb.Append((Convert-WikilinksInSegment -Text $Text.Substring($lastIndex))) + [void]$sb.Append((Convert-WikilinksInSegment -Text $Text.Substring($lastIndex) -RepoRoot $RepoRoot -CurrentDir $CurrentDir)) return $sb.ToString() } @@ -265,8 +321,10 @@ function ConvertTo-OkfFile { } else { $FilePath } + $currentDir = (Split-Path -Parent $relativePath) -replace '\\', '/' + if ($currentDir -eq '.') { $currentDir = '' } $fm = Get-FrontMatter -Content $original - $fm.Body = Convert-Wikilinks -Text $fm.Body + $fm.Body = Convert-Wikilinks -Text $fm.Body -RepoRoot $repoRoot -CurrentDir $currentDir $lines = [System.Collections.Generic.List[string]]::new() $lines.AddRange([string[]]$fm.Lines) diff --git a/tests/convert-to-okf.Tests.ps1 b/tests/convert-to-okf.Tests.ps1 index 922fc587..42616093 100644 --- a/tests/convert-to-okf.Tests.ps1 +++ b/tests/convert-to-okf.Tests.ps1 @@ -62,6 +62,29 @@ Describe 'ai-agents/skills/project-brain/scripts/convert-to-okf.ps1' { (Get-Content -LiteralPath $file -Raw) | Should -Match ([regex]::Escape('[bar](/research/bar.md)')) } + It 'resolves a bare-filename wikilink target (no path segment) to its real on-disk location, not the bundle root' { + $script:Repo = New-TestRepo + New-Item -ItemType Directory -Path (Join-Path $script:Repo 'tickets') -Force | Out-Null + $file = Join-Path $script:Repo 'tickets/index.md' + Set-Content -LiteralPath $file -Value "See [[T9a-ado-prerequisites]]." -NoNewline -Encoding utf8 + Set-Content -LiteralPath (Join-Path $script:Repo 'tickets/T9a-ado-prerequisites.md') -Value 'target' -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $script:Repo | Should -Be 0 + $content = Get-Content -LiteralPath $file -Raw + $content | Should -Match ([regex]::Escape('[T9a-ado-prerequisites](/tickets/T9a-ado-prerequisites.md)')) + } + + It 'falls back to bundle-root-relative when a bare-filename wikilink target has no matching file on disk' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[nonexistent-target]]." -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Match ([regex]::Escape('[nonexistent-target](/nonexistent-target.md)')) + } + It 'does not double-append .md to a bare wikilink target that already ends in .md' { $script:Repo = New-TestRepo $file = Join-Path $script:Repo 'core.md' From 96ab75d4addf3caf13f05388264847050679e6ac Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Mon, 24 Aug 2026 23:43:31 +1000 Subject: [PATCH 2/5] fix(project-brain): generalize wikilink resolution to path-containing targets too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior fix on this branch only handled bare-filename (no '/') wikilink targets. Batch D+E (#201) hit the same root-cause bug for path-containing targets: a source [[adr/0001-x]] link is Obsidian-vault-relative to the initiative directory, not the whole bundle root, so treating it as bundle-root-relative literally produced a dead link whenever that literal path didn't exist. Now: trust the literal bundle-root-relative path only when it actually resolves on disk; otherwise fall back to the same basename lookup used for bare targets, keyed on the target's last path segment. 2 more tests (wrong-base path resolves correctly; already-correct literal path is left as-is). 46/46 tests pass; re-scanned the whole Hollard brain repo afterward — 0 new broken links, the 22 remaining are pre-existing (external local-only wiki + archived sibling initiatives), unchanged from before this fix. --- .../project-brain/scripts/convert-to-okf.ps1 | 17 +++++++++--- tests/convert-to-okf.Tests.ps1 | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 index f6779319..e6cc842d 100644 --- a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 +++ b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 @@ -146,10 +146,19 @@ function Format-WikilinkTarget { # — leave its extension as-is instead of appending a wrong ".md" suffix. return "/$Target$fragment" } - if ($RepoRoot -and $Target -notmatch '/') { - # Bare filename, no path segment — do not assume bundle-root, look it up. - $resolved = Resolve-BareWikilinkTarget -Target $Target -RepoRoot $RepoRoot -CurrentDir $CurrentDir - if ($resolved) { return "/$resolved$fragment" } + if ($RepoRoot) { + # A wikilink target's path segment (if any) is Obsidian-vault-relative, which in + # this brain's layout usually means relative to the initiative directory, not the + # bundle root — so treating it as bundle-root-relative literally is only safe when + # that literal path actually exists. When it doesn't, fall back to a basename + # lookup keyed on just the last path segment (same as the no-slash case) rather + # than assuming the wrong base and emitting a dead link. + $literalMdPath = Join-Path $RepoRoot ("$Target.md" -replace '/', [IO.Path]::DirectorySeparatorChar) + if (-not (Test-Path -LiteralPath $literalMdPath)) { + $bareName = ($Target -split '/')[-1] + $resolved = Resolve-BareWikilinkTarget -Target $bareName -RepoRoot $RepoRoot -CurrentDir $CurrentDir + if ($resolved) { return "/$resolved$fragment" } + } } return "/$Target.md$fragment" } diff --git a/tests/convert-to-okf.Tests.ps1 b/tests/convert-to-okf.Tests.ps1 index 42616093..8af4a650 100644 --- a/tests/convert-to-okf.Tests.ps1 +++ b/tests/convert-to-okf.Tests.ps1 @@ -75,6 +75,32 @@ Describe 'ai-agents/skills/project-brain/scripts/convert-to-okf.ps1' { $content | Should -Match ([regex]::Escape('[T9a-ado-prerequisites](/tickets/T9a-ado-prerequisites.md)')) } + It 'resolves a path-containing wikilink target whose path segment is relative to the wrong base, not the bundle root' { + $script:Repo = New-TestRepo + New-Item -ItemType Directory -Path (Join-Path $script:Repo 'initiatives/foo/adr') -Force | Out-Null + $file = Join-Path $script:Repo 'initiatives/foo/core.md' + Set-Content -LiteralPath $file -Value "See [[adr/0001-decision]]." -NoNewline -Encoding utf8 + Set-Content -LiteralPath (Join-Path $script:Repo 'initiatives/foo/adr/0001-decision.md') -Value 'target' -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $script:Repo | Should -Be 0 + $content = Get-Content -LiteralPath $file -Raw + $content | Should -Match ([regex]::Escape('[0001-decision](/initiatives/foo/adr/0001-decision.md)')) + } + + It 'keeps a path-containing wikilink target as-is when it already resolves literally from the bundle root' { + $script:Repo = New-TestRepo + New-Item -ItemType Directory -Path (Join-Path $script:Repo 'adr') -Force | Out-Null + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[adr/0001-decision]]." -NoNewline -Encoding utf8 + Set-Content -LiteralPath (Join-Path $script:Repo 'adr/0001-decision.md') -Value 'target' -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + $content = Get-Content -LiteralPath $file -Raw + $content | Should -Match ([regex]::Escape('[0001-decision](/adr/0001-decision.md)')) + } + It 'falls back to bundle-root-relative when a bare-filename wikilink target has no matching file on disk' { $script:Repo = New-TestRepo $file = Join-Path $script:Repo 'core.md' From 7502119c534f53ca03811337174aaac9d2bb9ef8 Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Tue, 25 Aug 2026 00:00:21 +1000 Subject: [PATCH 3/5] fix(project-brain): fix PowerShell single-match array unwrap in wikilink resolution Where-Object unwraps a single-element result to a bare string instead of a 1-element array; indexing that string with [0] returned its first character ('i' from a path starting with 'initiatives/'), not the intended array element, silently producing a broken one-character link target whenever exactly one same-directory match existed among several same-basename candidates. Wrap the pipeline result in @() and check .Count instead of truthiness. --- .../skills/project-brain/scripts/convert-to-okf.ps1 | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 index e6cc842d..68f0ac14 100644 --- a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 +++ b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 @@ -98,11 +98,14 @@ function Resolve-BareWikilinkTarget { $found = $index[$key] if ($found.Count -eq 1) { return $found[0] } - $sameDir = $found | Where-Object { + # Wrap in @() — Where-Object unwraps a single match to a bare string rather than a + # 1-element array, and indexing a string with [0] returns its first character, not + # the string itself (silently resolving to a garbage one-letter path). + $sameDir = @($found | Where-Object { $dir = if ($_ -match '^(.*)/[^/]+$') { $Matches[1] } else { '' } $dir -eq $CurrentDir - } - if ($sameDir) { + }) + if ($sameDir.Count -gt 0) { Write-Warning "convert-to-okf.ps1: ambiguous wikilink target '$Target' ($($found.Count) matches) — resolved to same-directory match '$($sameDir[0])'." return $sameDir[0] } From aaeeb858f25f686f37617aa8cff0c8fc05279c12 Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Tue, 25 Aug 2026 00:35:28 +1000 Subject: [PATCH 4/5] fix(project-brain): resolve path-containing wikilinks against the linking file's own directory tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fable + Codex review-fix-loop (2 independent reviewers, both confirmed): 1. HIGH (Codex) / MEDIUM (Fable) — the path-containing fallback discarded a wikilink target's own directory segment before the basename lookup, so a target like [[adr/0001-x]] could silently resolve to a same-named file in an unrelated initiative when duplicate basenames exist across the brain repo. Fixed: walk up from the linking file's own directory (Obsidian same-vault semantics — relative to the initiative root, not the bundle root) trying '/.md' at each ancestor before ever widening to a basename-only search; the basename search itself now prefers a candidate whose path still preserves the target's own segment. 3 new tests (cross-initiative duplicate resolves correctly; bare-target ambiguity resolves via same-directory preference and via sorted-fallback — closing a real test gap in the prior commit's ambiguity branch too). 2. MEDIUM (both) — Get-WikilinkIndex silently swallowed enumeration errors (-ErrorAction SilentlyContinue), which could cache an incomplete index and degrade every subsequent lookup to a wrong or dead link with no signal why. Removed; a real traversal failure now terminates per the script's existing $ErrorActionPreference = 'Stop' convention instead of masquerading as 'no such file'. 49/49 tests pass, PSScriptAnalyzer clean. Already-migrated brain-repo content is unaffected (idempotent — the fix only changes behavior for literal [[wikilink]] syntax, and none remains in already-converted files). --- .../project-brain/scripts/convert-to-okf.ps1 | 83 +++++++++++++++---- tests/convert-to-okf.Tests.ps1 | 46 ++++++++++ 2 files changed, 113 insertions(+), 16 deletions(-) diff --git a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 index 68f0ac14..6942e96f 100644 --- a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 +++ b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 @@ -74,7 +74,11 @@ function Get-WikilinkIndex { } $index = @{} - $mdFiles = Get-ChildItem -LiteralPath $RepoRoot -Filter '*.md' -File -Recurse -ErrorAction SilentlyContinue | + # No -ErrorAction SilentlyContinue: a swallowed enumeration failure here would silently + # cache an incomplete index, degrading every subsequent lookup to a wrong or dead link + # with no signal of why — let it terminate ($ErrorActionPreference = 'Stop' above) so a + # real traversal failure surfaces instead of masquerading as "no such file". + $mdFiles = Get-ChildItem -LiteralPath $RepoRoot -Filter '*.md' -File -Recurse | Where-Object { $_.FullName -notmatch '[\\/]\.git[\\/]' } foreach ($f in $mdFiles) { $rel = ([IO.Path]::GetRelativePath($RepoRoot, $f.FullName)) -replace '\\', '/' @@ -88,32 +92,76 @@ function Get-WikilinkIndex { return $index } -function Resolve-BareWikilinkTarget { - param([string] $Target, [string] $RepoRoot, [string] $CurrentDir) - - $index = Get-WikilinkIndex -RepoRoot $RepoRoot - $key = $Target.ToLowerInvariant() - if (-not $index.ContainsKey($key)) { return $null } - - $found = $index[$key] - if ($found.Count -eq 1) { return $found[0] } +function Resolve-AmbiguousMatch { + param([string[]] $Found, [string] $Target, [string] $CurrentDir) # Wrap in @() — Where-Object unwraps a single match to a bare string rather than a # 1-element array, and indexing a string with [0] returns its first character, not # the string itself (silently resolving to a garbage one-letter path). - $sameDir = @($found | Where-Object { + $sameDir = @($Found | Where-Object { $dir = if ($_ -match '^(.*)/[^/]+$') { $Matches[1] } else { '' } $dir -eq $CurrentDir }) if ($sameDir.Count -gt 0) { - Write-Warning "convert-to-okf.ps1: ambiguous wikilink target '$Target' ($($found.Count) matches) — resolved to same-directory match '$($sameDir[0])'." + Write-Warning "convert-to-okf.ps1: ambiguous wikilink target '$Target' ($($Found.Count) matches) — resolved to same-directory match '$($sameDir[0])'." return $sameDir[0] } - $picked = ($found | Sort-Object)[0] - Write-Warning "convert-to-okf.ps1: ambiguous wikilink target '$Target' ($($found.Count) matches: $($found -join ', ')) — defaulted to '$picked'; verify manually." + $picked = ($Found | Sort-Object)[0] + Write-Warning "convert-to-okf.ps1: ambiguous wikilink target '$Target' ($($Found.Count) matches: $($Found -join ', ')) — defaulted to '$picked'; verify manually." return $picked } +function Resolve-BareWikilinkTarget { + param([string] $Target, [string] $RepoRoot, [string] $CurrentDir) + + $index = Get-WikilinkIndex -RepoRoot $RepoRoot + $key = $Target.ToLowerInvariant() + if (-not $index.ContainsKey($key)) { return $null } + + $found = $index[$key] + if ($found.Count -eq 1) { return $found[0] } + return Resolve-AmbiguousMatch -Found $found -Target $Target -CurrentDir $CurrentDir +} + +function Resolve-PathWikilinkTarget { + param([string] $Target, [string] $RepoRoot, [string] $CurrentDir) + + # Obsidian same-vault resolution is relative to the linking note's own directory tree + # (in this brain, effectively the initiative root), not the whole bundle root. Walk up + # from the linking file's own directory, trying "$Target.md" against each ancestor in + # turn, so "adr/0001-x" resolves against the initiative that actually owns it instead + # of colliding with a same-named file in an unrelated initiative, or double-counting a + # path segment ($CurrentDir already inside "adr/") that a naive join would repeat. + $dir = $CurrentDir + while ($true) { + if ($dir) { + $candidate = Join-Path $RepoRoot ("$dir/$Target.md" -replace '/', [IO.Path]::DirectorySeparatorChar) + if (Test-Path -LiteralPath $candidate) { return "$dir/$Target.md" } + } + if (-not $dir) { break } + $parent = if ($dir -match '^(.*)/[^/]+$') { $Matches[1] } else { '' } + if ($parent -eq $dir) { break } + $dir = $parent + } + + # No ancestor-relative match — widen to a basename lookup across the whole repo, + # preferring a candidate whose real path still ends with the target's own path + # segment over one that merely shares its final filename. + $bareName = ($Target -split '/')[-1] + $index = Get-WikilinkIndex -RepoRoot $RepoRoot + $key = $bareName.ToLowerInvariant() + if (-not $index.ContainsKey($key)) { return $null } + + $found = $index[$key] + $suffix = "/$Target.md" + $preserving = @($found | Where-Object { "/$_" -like "*$suffix" }) + if ($preserving.Count -eq 1) { return $preserving[0] } + if ($preserving.Count -gt 1) { return Resolve-AmbiguousMatch -Found $preserving -Target $Target -CurrentDir $CurrentDir } + + if ($found.Count -eq 1) { return $found[0] } + return Resolve-AmbiguousMatch -Found $found -Target $Target -CurrentDir $CurrentDir +} + function Format-WikilinkTarget { param([string] $Target, [string] $RepoRoot, [string] $CurrentDir) @@ -158,8 +206,11 @@ function Format-WikilinkTarget { # than assuming the wrong base and emitting a dead link. $literalMdPath = Join-Path $RepoRoot ("$Target.md" -replace '/', [IO.Path]::DirectorySeparatorChar) if (-not (Test-Path -LiteralPath $literalMdPath)) { - $bareName = ($Target -split '/')[-1] - $resolved = Resolve-BareWikilinkTarget -Target $bareName -RepoRoot $RepoRoot -CurrentDir $CurrentDir + $resolved = if ($Target -match '/') { + Resolve-PathWikilinkTarget -Target $Target -RepoRoot $RepoRoot -CurrentDir $CurrentDir + } else { + Resolve-BareWikilinkTarget -Target $Target -RepoRoot $RepoRoot -CurrentDir $CurrentDir + } if ($resolved) { return "/$resolved$fragment" } } } diff --git a/tests/convert-to-okf.Tests.ps1 b/tests/convert-to-okf.Tests.ps1 index 8af4a650..972992f3 100644 --- a/tests/convert-to-okf.Tests.ps1 +++ b/tests/convert-to-okf.Tests.ps1 @@ -88,6 +88,52 @@ Describe 'ai-agents/skills/project-brain/scripts/convert-to-okf.ps1' { $content | Should -Match ([regex]::Escape('[0001-decision](/initiatives/foo/adr/0001-decision.md)')) } + It 'resolves a path-containing target to the initiative matching its own path segment, not a same-basename file in another initiative' { + $script:Repo = New-TestRepo + New-Item -ItemType Directory -Path (Join-Path $script:Repo 'initiatives/foo/adr') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:Repo 'initiatives/bar/adr') -Force | Out-Null + $file = Join-Path $script:Repo 'initiatives/foo/core.md' + Set-Content -LiteralPath $file -Value "See [[adr/0001-decision]]." -NoNewline -Encoding utf8 + Set-Content -LiteralPath (Join-Path $script:Repo 'initiatives/foo/adr/0001-decision.md') -Value 'foo target' -NoNewline -Encoding utf8 + Set-Content -LiteralPath (Join-Path $script:Repo 'initiatives/bar/adr/0001-decision.md') -Value 'bar target' -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $script:Repo | Should -Be 0 + $content = Get-Content -LiteralPath $file -Raw + $content | Should -Match ([regex]::Escape('[0001-decision](/initiatives/foo/adr/0001-decision.md)')) + $content | Should -Not -Match ([regex]::Escape('/initiatives/bar/')) + } + + It 'resolves an ambiguous bare-filename target to the same-directory match when one exists' { + $script:Repo = New-TestRepo + New-Item -ItemType Directory -Path (Join-Path $script:Repo 'initiatives/foo/adr') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:Repo 'initiatives/bar/adr') -Force | Out-Null + $file = Join-Path $script:Repo 'initiatives/foo/adr/index.md' + Set-Content -LiteralPath $file -Value "See [[0001-decision]]." -NoNewline -Encoding utf8 + Set-Content -LiteralPath (Join-Path $script:Repo 'initiatives/foo/adr/0001-decision.md') -Value 'foo target' -NoNewline -Encoding utf8 + Set-Content -LiteralPath (Join-Path $script:Repo 'initiatives/bar/adr/0001-decision.md') -Value 'bar target' -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + $content = Get-Content -LiteralPath $file -Raw + $content | Should -Match ([regex]::Escape('[0001-decision](/initiatives/foo/adr/0001-decision.md)')) + } + + It 'resolves an ambiguous bare-filename target with no same-directory match to the sorted-first candidate' { + $script:Repo = New-TestRepo + New-Item -ItemType Directory -Path (Join-Path $script:Repo 'initiatives/aaa/adr') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:Repo 'initiatives/zzz/adr') -Force | Out-Null + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[0001-decision]]." -NoNewline -Encoding utf8 + Set-Content -LiteralPath (Join-Path $script:Repo 'initiatives/aaa/adr/0001-decision.md') -Value 'aaa target' -NoNewline -Encoding utf8 + Set-Content -LiteralPath (Join-Path $script:Repo 'initiatives/zzz/adr/0001-decision.md') -Value 'zzz target' -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + $content = Get-Content -LiteralPath $file -Raw + $content | Should -Match ([regex]::Escape('[0001-decision](/initiatives/aaa/adr/0001-decision.md)')) + } + It 'keeps a path-containing wikilink target as-is when it already resolves literally from the bundle root' { $script:Repo = New-TestRepo New-Item -ItemType Directory -Path (Join-Path $script:Repo 'adr') -Force | Out-Null From 59def9b381860e1d2c56ff6d2f2a8ce574089a83 Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Tue, 25 Aug 2026 00:40:32 +1000 Subject: [PATCH 5/5] fix(project-brain): use ordinal EndsWith instead of -like for suffix match Fable cycle-2 review caught: the wildcard-injection risk in Resolve-PathWikilinkTarget's suffix filter -- a raw wikilink target containing an unclosed '[' throws WildcardPatternException under -like (verified), aborting the whole conversion run; '*'/'?' in a target also carry unintended wildcard meaning. Ordinal EndsWith has neither problem. 49/49 tests still pass. --- ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 index 6942e96f..0d4b8165 100644 --- a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 +++ b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 @@ -153,8 +153,11 @@ function Resolve-PathWikilinkTarget { if (-not $index.ContainsKey($key)) { return $null } $found = $index[$key] + # Ordinal EndsWith, not -like — $Target is raw wikilink text and may legally contain + # wildcard-meaningful characters (e.g. an unclosed '[' from a Markdown-ish source), which + # -like would either match unintended candidates or throw WildcardPatternException. $suffix = "/$Target.md" - $preserving = @($found | Where-Object { "/$_" -like "*$suffix" }) + $preserving = @($found | Where-Object { "/$_".EndsWith($suffix, [StringComparison]::OrdinalIgnoreCase) }) if ($preserving.Count -eq 1) { return $preserving[0] } if ($preserving.Count -gt 1) { return Resolve-AmbiguousMatch -Found $preserving -Target $Target -CurrentDir $CurrentDir }