Public/Invoke-KritMdLint.ps1
|
function Invoke-KritMdLint { <# .SYNOPSIS Programmatic markdown lint + auto-renumber for Kritical customer-facing documents. .DESCRIPTION Repeatable SOP linter. Six deterministic passes: 1. STRIKETHROUGH SCRUB — removes `~~text~~` (markdown strikethrough means deleted). 2. CLOSED-ITEM SCRUB — removes checklist items annotated `*(closed — ...)*`, `*(confirmed — ...)*`, `*(deferred — ...)*`, `*(already known)*`, `*(already in place)*`. 3. SECTION AUTO-RELETTER — re-letters `## A. / ## B. / ...` sequentially from A. Handles duplicate letters (forces uniqueness). 4. ITEM CODE AUTO-RENUMBER — within each section, renumbers `**A1** / **A2** / ...` sequentially 1, 2, 3... with the new section letter. 5. CROSS-REFERENCE REWRITE — body-text refs like "items G1-G3" / "row K7" follow the old→new map. Conservative match — only triggers on word-boundary contexts to avoid SKU false positives like MST-NCE-103-C100. 6. EMPTY-SECTION SCRUB — removes section headings with no content underneath. Dry-run by default. Pass -Apply to write. .PARAMETER Path File OR directory. Directory = all *.md. .PARAMETER Apply Write changes back. Default dry-run. .PARAMETER Filter Glob filter when Path is a directory. Default '*.md'. .PARAMETER NoStrikethrough / NoClosedItems / NoRenumber / NoEmptySection Skip individual passes. .PARAMETER Quiet Suppress console output. .EXAMPLE Invoke-KritMdLint -Path C:\path\to\Access-Checklist.md # dry-run Invoke-KritMdLint -Path C:\path\to\Access-Checklist.md -Apply # write Invoke-KritMdLint -Path C:\path\to\customer\folder -Apply # bulk .NOTES Author: Joshua Finley — Kritical Pty Ltd Part of Krit.OmniFramework 1.1.8+ #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][string]$Path, [switch]$Apply, [string]$Filter = '*.md', [switch]$NoStrikethrough, [switch]$NoClosedItems, [switch]$NoRenumber, [switch]$NoEmptySection, [switch]$Quiet ) $targets = @() if (Test-Path -LiteralPath $Path -PathType Container) { $targets = Get-ChildItem -LiteralPath $Path -Filter $Filter -File | Select-Object -ExpandProperty FullName } elseif (Test-Path -LiteralPath $Path -PathType Leaf) { $targets = @((Resolve-Path -LiteralPath $Path).Path) } else { throw "Path not found: $Path" } $results = [System.Collections.Generic.List[pscustomobject]]::new() foreach ($file in $targets) { $orig = Get-Content -LiteralPath $file -Raw -Encoding UTF8 if (-not $orig) { continue } $text = $orig $report = [System.Collections.Generic.List[string]]::new() $letterMap = [ordered]@{} # ---- PASS 1 — STRIKETHROUGH SCRUB -------------------------------------- if (-not $NoStrikethrough) { $strikePat = '~~[^~]+~~' $cnt = ([regex]::Matches($text, $strikePat)).Count if ($cnt -gt 0) { $text = [regex]::Replace($text, $strikePat, '') $report.Add("Pass 1: removed $cnt strikethrough(s)") } } # ---- PASS 2 — CLOSED-ITEM SCRUB ---------------------------------------- if (-not $NoClosedItems) { $closedPat = '(?m)^\s*-\s+\[[ x]\]\s+(?:\*\*[A-Z]\d+\*\*\s+)?\*\((?:closed|confirmed|deferred[^)]*|already[^)]*|done|noop)\b[^)]*\)\*[^\r\n]*\r?\n' $cnt = ([regex]::Matches($text, $closedPat)).Count if ($cnt -gt 0) { $text = [regex]::Replace($text, $closedPat, '') $report.Add("Pass 2: removed $cnt closed/confirmed/deferred item(s)") } } # ---- PASS 3 + 4 + 5 — SECTION + ITEM + CROSS-REF RENUMBER (single-pass) - if (-not $NoRenumber) { # Step A: walk text, find all section headings in document order, build old→new map. $sectionPat = '(?m)^(##+ )([A-Z])(\. [^\r\n]+)$' $sectionMatches = [regex]::Matches($text, $sectionPat) $next = [int][char]'A' foreach ($m in $sectionMatches) { $newLetter = [char]$next # Every section heading gets the next sequential letter regardless of its old letter. # We use INDEX as the key (since duplicate old-letters exist), but we also map by # old-letter for cross-ref lookups (first occurrence wins for cross-ref purposes). $oldLetter = $m.Groups[2].Value if (-not $letterMap.Contains($oldLetter)) { $letterMap[$oldLetter] = $newLetter.ToString() } $next++ } # Step B: collect ALL match positions in original text: # - section headings # - bold item codes **[A-Z]\d+** # - bare cross-reference codes [A-Z]\d+ (word-boundary-aware) # Then walk linearly, emit unchanged chars + rewritten matches. # ITEM = a bold code at the START of a checklist row `- [ ] **X1** ...` or `- [ ] **X1 (label)** ...` # Group 1 = checkbox prefix (preserved), Group 2 = letter, Group 3 = num, Group 4 = trailing content inside bold. $itemPat = '(?m)^(\s*-\s+\[[ x]\]\s+)\*\*([A-Z])(\d+)([^\*]*)\*\*' # BOLD-REF = a `**X1**` bold code anywhere ELSE (in body intros, paragraphs) → cross-ref, doesn't increment counter $boldRefPat = '\*\*([A-Z])(\d+)([^\*]*)\*\*' # CROSS-REF in plain text = `X1` with safe word-boundary, gated by valid-item set $crossPat = '(?<=[\s\(\[\>/\-])([A-Z])(\d+)(?=[\s\.\,\)\]\:\;\?\!\-/]|$)' # Build the set of OLD item codes that actually exist as checklist-row items. # Cross-references are only rewritten when the matched (letter,num) is in this set. $validOldItemCodes = [System.Collections.Generic.HashSet[string]]::new() foreach ($im in [regex]::Matches($text, $itemPat)) { [void]$validOldItemCodes.Add("$($im.Groups[2].Value)$($im.Groups[3].Value)") } $events = [System.Collections.Generic.List[pscustomobject]]::new() # Sections: each has its OWN new letter based on its document-order index. $sectionOrder = 0 $nextLetter = [int][char]'A' foreach ($m in $sectionMatches) { $newL = [char]($nextLetter + $sectionOrder) $events.Add([pscustomobject]@{ Index = $m.Index; Length = $m.Length; Kind = 'section' Replacement = "$($m.Groups[1].Value)${newL}$($m.Groups[3].Value)" OldLetter = $m.Groups[2].Value; NewLetter = $newL.ToString() }) $sectionOrder++ } $itemPositions = [System.Collections.Generic.HashSet[int]]::new() foreach ($m in [regex]::Matches($text, $itemPat)) { # The whole match starts at $m.Index (includes the checkbox prefix). # We want the event Index to be at the START of `**X1**` to keep replacement aligned. $prefixLen = $m.Groups[1].Length $boldStart = $m.Index + $prefixLen $boldLen = $m.Length - $prefixLen $events.Add([pscustomobject]@{ Index = $boldStart; Length = $boldLen; Kind = 'item' OldLetter = $m.Groups[2].Value; OldNum = $m.Groups[3].Value TrailingText = $m.Groups[4].Value }) [void]$itemPositions.Add($boldStart) } # In-text bold refs (not checklist-row items) → treat as bold cross-refs (preserve bold markers, rewrite letter) foreach ($m in [regex]::Matches($text, $boldRefPat)) { if ($itemPositions.Contains($m.Index)) { continue } $events.Add([pscustomobject]@{ Index = $m.Index; Length = $m.Length; Kind = 'boldref' Match = $m; OldLetter = $m.Groups[1].Value; OldNum = $m.Groups[2].Value TrailingText = $m.Groups[3].Value }) } foreach ($m in [regex]::Matches($text, $crossPat)) { $events.Add([pscustomobject]@{ Index = $m.Index; Length = $m.Length; Kind = 'cross' Match = $m; OldLetter = $m.Groups[1].Value; OldNum = $m.Groups[2].Value }) } # v0.2 — section-letter ranges like "B-H" / "sections A-H" / "items B–G" where BOTH # endpoints map to known sections. Guarded — letters must NOT be adjacent to word chars # (avoids matching inside hyphenated words like "test-tenant" — t and t aren't single caps). # Pattern: word-boundary, single-cap, dash (regular or en-dash), single-cap, word-boundary. $sectionRangePat = '(?<![A-Za-z0-9])([A-Z])[\-–]([A-Z])(?![A-Za-z0-9])' foreach ($m in [regex]::Matches($text, $sectionRangePat)) { $startL = $m.Groups[1].Value $endL = $m.Groups[2].Value if ($letterMap.Contains($startL) -and $letterMap.Contains($endL)) { $events.Add([pscustomobject]@{ Index = $m.Index; Length = $m.Length; Kind = 'sectionrange' Match = $m; StartOld = $startL; EndOld = $endL }) } } # Sort by Index $events = $events | Sort-Object Index # Items use their section's NEW letter (from sectionOrder walk). # Determine current section new-letter as we walk. $sb = [System.Text.StringBuilder]::new() $cursor = 0 $currentNewLetter = $null $itemCounter = @{} foreach ($evt in $events) { if ($evt.Index -lt $cursor) { continue } # overlap protection [void]$sb.Append($text.Substring($cursor, $evt.Index - $cursor)) if ($evt.Kind -eq 'section') { $currentNewLetter = $evt.NewLetter $itemCounter[$currentNewLetter] = 0 [void]$sb.Append($evt.Replacement) } elseif ($evt.Kind -eq 'item') { if (-not $currentNewLetter) { # Item appearing before any section — leave alone [void]$sb.Append($evt.Match.Value) } else { if (-not $itemCounter.Contains($currentNewLetter)) { $itemCounter[$currentNewLetter] = 0 } $itemCounter[$currentNewLetter]++ # Preserve any trailing text inside the bold markers (e.g. "(recommended)") $trailing = $evt.TrailingText [void]$sb.Append("**${currentNewLetter}$($itemCounter[$currentNewLetter])${trailing}**") } } elseif ($evt.Kind -eq 'boldref') { # In-text bold reference like `**G1**` in body intro — rewrite letter via map, keep bold + trailing text $oldL = $evt.OldLetter $oldN = $evt.OldNum $oldCode = "${oldL}${oldN}" if ($letterMap.Contains($oldL) -and $validOldItemCodes.Contains($oldCode)) { $newL = $letterMap[$oldL] [void]$sb.Append("**${newL}${oldN}$($evt.TrailingText)**") } else { [void]$sb.Append($evt.Match.Value) } } elseif ($evt.Kind -eq 'sectionrange') { # Section-letter range like "B-H" — rewrite both endpoints via section map $newStart = $letterMap[$evt.StartOld] $newEnd = $letterMap[$evt.EndOld] # Preserve the dash character (regular - or en-dash – ) from the original match $dashChar = $evt.Match.Value.Substring(1, 1) [void]$sb.Append("${newStart}${dashChar}${newEnd}") } else { # Plain-text cross-reference — gated on the valid-item-set so D365/M365/E5 etc. stay intact $oldL = $evt.OldLetter $oldN = $evt.OldNum $oldCode = "${oldL}${oldN}" if ($letterMap.Contains($oldL) -and $validOldItemCodes.Contains($oldCode)) { $newL = $letterMap[$oldL] [void]$sb.Append("${newL}${oldN}") } else { [void]$sb.Append($evt.Match.Value) } } $cursor = $evt.Index + $evt.Length } [void]$sb.Append($text.Substring($cursor)) $text = $sb.ToString() # Report $changedSections = 0 $i = 0 foreach ($m in $sectionMatches) { $expectedNew = [char]([int][char]'A' + $i) if ($m.Groups[2].Value -ne $expectedNew.ToString()) { $changedSections++ } $i++ } if ($changedSections -gt 0) { $summary = ($letterMap.GetEnumerator() | Where-Object { $_.Key -ne $_.Value } | ForEach-Object { "$($_.Key)→$($_.Value)" }) -join ', ' $report.Add("Pass 3-5: re-lettered $changedSections section(s) + items + cross-refs: $summary") } } # ---- PASS 6 — EMPTY-SECTION SCRUB -------------------------------------- if (-not $NoEmptySection) { $emptyPat = '(?ms)^##+ [A-Z]\. [^\r\n]+\r?\n(?:[\s\-]*\r?\n)*(?=##|\z|---)' $cnt = ([regex]::Matches($text, $emptyPat)).Count if ($cnt -gt 0) { $text = [regex]::Replace($text, $emptyPat, '') $report.Add("Pass 6: removed $cnt empty section(s)") } } # Collapse 3+ consecutive blank lines to 2 $text = [regex]::Replace($text, '(\r?\n){4,}', "`n`n`n") # ---- Write or report ---------------------------------------------------- $modified = ($text -ne $orig) if ($Apply -and $modified) { if ($PSCmdlet.ShouldProcess($file, 'Apply MdLint changes')) { Set-Content -LiteralPath $file -Value $text -Encoding UTF8 $report.Add("WRITTEN") } } elseif ($modified) { $report.Add("DRY-RUN: would write") } else { $report.Add("clean") } $result = [pscustomobject]@{ Path = $file Modified = $modified Changes = $report.ToArray() LetterMap = $letterMap } $results.Add($result) if (-not $Quiet) { $color = if ($modified) { 'Yellow' } else { 'DarkGray' } Write-Host ("[{0}] {1}" -f (Split-Path -Leaf $file), ($report -join ' · ')) -ForegroundColor $color } } return $results } |