Private/Test-DhNavLint.ps1

function Get-DhEditDistance {
    <# Levenshtein distance between two strings (small inputs: menu segment names). #>
    param([string] $A, [string] $B)
    $m = $A.Length; $n = $B.Length
    if ($m -eq 0) { return $n }
    if ($n -eq 0) { return $m }
    $prev = 0..$n
    for ($i = 1; $i -le $m; $i++) {
        $cur = @($i) + @(0) * $n
        for ($j = 1; $j -le $n; $j++) {
            $cost = if ($A[$i - 1] -ceq $B[$j - 1]) { 0 } else { 1 }
            $cur[$j] = [math]::Min([math]::Min($cur[$j - 1] + 1, $prev[$j] + 1), $prev[$j - 1] + $cost)
        }
        $prev = $cur
    }
    return $prev[$n]
}

function Test-DhNavLint {
    <#
    .SYNOPSIS
        Likely menu mistakes in a report, as warning strings (New-DhDashboard -StrictNav).
    .DESCRIPTION
        Never throws. Returns nothing for a clean menu. Rules:
          * a path deeper than 3 levels (the strips stop fitting; -NavLayout Sidebar handles it);
          * sibling nodes whose labels are the same (case-insensitive);
          * sibling names that differ only in case ('Sub A' / 'Sub a': nav matching is case-sensitive);
          * sibling names one letter apart (probably a typo: 'Storage' / 'Storag');
          * a path that is not listed in -NavTree, when a -NavTree is given;
          * a -NavTree node with nothing on it or below it;
          * a Subtree item whose node has nothing below it (it behaves like Exact);
          * a -NavAllLabel that names no group node.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param([Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Report)

    $out = [System.Collections.Generic.List[string]]::new()
    $items = @(Get-DhNavItem -Report $Report | Where-Object { @($_.Path).Count -gt 0 })
    $tree = Get-DhNavTree -Report $Report

    foreach ($it in $items) {
        if (@($it.Path).Count -gt 3) { $out.Add("Nav lint: '$($it.Id)' sits at '$(@($it.Path) -join '/')', $(@($it.Path).Count) levels deep; more than 3 levels needs -NavLayout Sidebar and a longer reading path.") }
    }

    # the explicit -NavLabel of the first item on a node (else the menu shows the segment)
    $explicit = @{}
    foreach ($kind in 'Tables', 'Blocks') {
        if (-not $Report.Contains($kind)) { continue }
        foreach ($x in @($Report[$kind])) {
            if (-not $x -or -not $x.Contains('NavLabel') -or -not $x.NavLabel -or -not $x.Contains('NavSegments')) { continue }
            $k = (@($x.NavSegments) -join [char]0x1F)
            if ($k -and -not $explicit.ContainsKey($k)) { $explicit[$k] = [string]$x.NavLabel }
        }
    }

    # Siblings, compared case-sensitively (nav matching is). The PowerShell tree keys are case-insensitive,
    # so two names that differ only in case would be one node there; collect them from the raw paths.
    $siblings = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[string]]]::new([StringComparer]::Ordinal)
    $addPath = {
        param([string[]] $segs)
        for ($d = 0; $d -lt $segs.Count; $d++) {
            $parent = if ($d -eq 0) { '' } else { ($segs[0..($d - 1)] -join [char]0x1F) }
            if (-not $siblings.ContainsKey($parent)) { $siblings[$parent] = [System.Collections.Generic.List[string]]::new() }
            if (-not $siblings[$parent].Contains($segs[$d])) { $siblings[$parent].Add($segs[$d]) }
        }
    }
    foreach ($it in $items) { & $addPath @($it.Path) }
    foreach ($e in @(if ($Report.Contains('NavTree') -and $Report.NavTree) { $Report.NavTree })) { & $addPath @(ConvertFrom-DhNavPath ([string]$e.Path)) }

    foreach ($parent in $siblings.Keys) {
        $names = $siblings[$parent]
        $trail = @(if ($parent) { $parent -split [char]0x1F })
        $where = if ($trail.Count) { "under '$($trail -join '/')'" } else { 'at the top level' }
        for ($a2 = 0; $a2 -lt $names.Count; $a2++) {
            for ($b2 = $a2 + 1; $b2 -lt $names.Count; $b2++) {
                $sa = $names[$a2]; $sb = $names[$b2]
                if ($sa -ieq $sb) {
                    $out.Add("Nav lint: '$sa' and '$sb' $where differ only in case; nav matching is case-sensitive, so they are two menu items.")
                    continue
                }
                if ($sa.Length -ge 4 -and $sb.Length -ge 4 -and (Get-DhEditDistance $sa.ToLowerInvariant() $sb.ToLowerInvariant()) -eq 1) {
                    $out.Add("Nav lint: '$sa' and '$sb' $where are one letter apart; is one of them a typo?")
                }
                $ka = (@($trail) + $sa) -join [char]0x1F; $kb = (@($trail) + $sb) -join [char]0x1F
                $la = if ($explicit.ContainsKey($ka)) { $explicit[$ka] } else { $sa }
                $lb = if ($explicit.ContainsKey($kb)) { $explicit[$kb] } else { $sb }
                if ($la -ieq $lb) { $out.Add("Nav lint: '$sa' and '$sb' $where both show as '$la' in the menu.") }
            }
        }
    }
    # Subtree item with nothing below it
    foreach ($it in $items) {
        if ($it.Scope -ne 'Subtree') { continue }
        $n = $tree
        foreach ($s in @($it.Path)) { $n = $n.Children[$s] }
        if ($n -and $n.Children.Count -eq 0) {
            $out.Add("Nav lint: '$($it.Id)' is -NavScope Subtree at '$(@($it.Path) -join '/')', which has no views below it, so it behaves like Exact.")
        }
    }

    # -NavTree checks
    $skeleton = @(if ($Report.Contains('NavTree') -and $Report.NavTree) { $Report.NavTree })
    if ($skeleton.Count) {
        $declared = @{}
        foreach ($e in $skeleton) { $declared[((ConvertFrom-DhNavPath ([string]$e.Path)) -join [char]0x1F)] = $true }
        $seenMissing = @{}
        foreach ($it in $items) {
            $segs = @($it.Path)
            $k = ($segs -join [char]0x1F)
            if (-not $declared.ContainsKey($k) -and -not $seenMissing.ContainsKey($k)) {
                $seenMissing[$k] = $true
                $out.Add("Nav lint: '$($segs -join '/')' (used by '$($it.Id)') is not listed in -NavTree.")
            }
        }
        $used = @{}
        foreach ($it in $items) { $segs = @($it.Path); for ($d = 1; $d -le $segs.Count; $d++) { $used[(@($segs[0..($d - 1)]) -join [char]0x1F)] = $true } }
        foreach ($e in $skeleton) {
            $k = ((ConvertFrom-DhNavPath ([string]$e.Path)) -join [char]0x1F)
            if (-not $used.ContainsKey($k)) { $out.Add("Nav lint: -NavTree node '$($e.Path)' has no content on it or below it; the view will show a 'no content' note.") }
        }
    }

    # -NavAllLabel keys that name no group
    if ($Report.Contains('NavAllLabel') -and $Report.NavAllLabel -is [System.Collections.IDictionary]) {
        foreach ($k in $Report.NavAllLabel.Keys) {
            $n = $tree
            foreach ($s in (ConvertFrom-DhNavPath ([string]$k))) { if ($n) { $n = $n.Children[$s] } }
            if (-not $n -or $n.Children.Count -eq 0) { $out.Add("Nav lint: -NavAllLabel '$k' names no group (a menu node with views below it).") }
        }
    }
    return @($out)
}