diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 43288af9c..2f1fdd448 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -143,7 +143,7 @@ authorized manual actions. Local validation does not execute or verify them. | `frontmatter-validation.yml` | Custom PS script | YAML frontmatter validation | `soft-fail` (false), `changed-files-only` (true), `skip-footer-validation` (false), `warnings-as-errors` (true) | frontmatter-validation-results | | `skill-validation.yml` | Custom PS script | Skill directory structure validation | `soft-fail` (false), `changed-files-only` (true) | skill-validation-results | | `link-lang-check.yml` | Custom PS script | Detect language-specific URLs | `soft-fail` (false) | link-lang-check-results | -| `markdown-link-check.yml` | markdown-link-check | Validate links (internal/external) | `soft-fail` (true) | markdown-link-check-results | +| `markdown-link-check.yml` | markdown-link-check | Validate internal and external links | `soft-fail` (false), `changed-files-only` (true, external links only), `throttle-limit` (8) | markdown-link-check-results | All validation workflows use `permissions: contents: read`, publish PR annotations, and retain artifacts for 30 days. diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml index c8fe09149..4a39fa1a9 100644 --- a/.github/workflows/markdown-link-check.yml +++ b/.github/workflows/markdown-link-check.yml @@ -10,6 +10,21 @@ on: required: false type: boolean default: false + changed-files-only: + description: 'Scope external-link validation to markdown files changed against the base branch; internal links are always validated repository-wide' + required: false + type: boolean + default: true + base-branch: + description: 'Base branch or SHA for change detection (defaults to origin/main)' + required: false + type: string + default: '' + throttle-limit: + description: 'Maximum number of markdown-link-check processes run concurrently' + required: false + type: number + default: 8 permissions: contents: read @@ -25,6 +40,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -43,8 +59,19 @@ jobs: - name: Run markdown link check id: link-check shell: pwsh + env: + BASE_BRANCH: ${{ inputs.base-branch }} + CHANGED_FILES_ONLY: ${{ inputs.changed-files-only }} + THROTTLE_LIMIT: ${{ inputs.throttle-limit }} run: | - & scripts/linting/Markdown-Link-Check.ps1 + $params = @{ ThrottleLimit = [int]$env:THROTTLE_LIMIT } + if ($env:CHANGED_FILES_ONLY -eq 'true') { + $params['ChangedFilesOnly'] = $true + if (-not [string]::IsNullOrEmpty($env:BASE_BRANCH)) { + $params['BaseBranch'] = $env:BASE_BRANCH + } + } + & scripts/linting/Markdown-Link-Check.ps1 @params continue-on-error: ${{ inputs.soft-fail }} - name: Upload markdown link check results @@ -54,6 +81,7 @@ jobs: name: markdown-link-check-results path: logs/markdown-link-check-results.json retention-days: 30 + if-no-files-found: ignore - name: Check results and fail if needed if: ${{ !inputs.soft-fail && steps.link-check.outcome == 'failure' }} diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index b012e84e3..29f28acb9 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -389,6 +389,8 @@ jobs: contents: read with: soft-fail: true + changed-files-only: true + base-branch: ${{ github.event.pull_request.base.sha || format('origin/{0}', github.base_ref) }} dependency-pinning-check: name: Validate Dependency Pinning diff --git a/.github/workflows/weekly-validation.yml b/.github/workflows/weekly-validation.yml index ca404c000..68555012d 100644 --- a/.github/workflows/weekly-validation.yml +++ b/.github/workflows/weekly-validation.yml @@ -92,6 +92,15 @@ jobs: permissions: contents: read + markdown-link-check: + name: Markdown Link Check (Full Repository) + uses: ./.github/workflows/markdown-link-check.yml + permissions: + contents: read + with: + soft-fail: true + changed-files-only: false + create-stale-docs-issues: name: Create or Update Issues for Stale Documentation needs: [msdate-freshness] diff --git a/scripts/linting/Markdown-Link-Check.ps1 b/scripts/linting/Markdown-Link-Check.ps1 index 2845714bb..946a1bb18 100644 --- a/scripts/linting/Markdown-Link-Check.ps1 +++ b/scripts/linting/Markdown-Link-Check.ps1 @@ -8,9 +8,12 @@ Repository-aware wrapper for markdown-link-check. .DESCRIPTION - Runs markdown-link-check with the repo-specific configuration to validate - all markdown links across the repository. Checks tracked and untracked, - nonignored files so local validation does not require staging. + Runs markdown-link-check with the repo-specific configuration. Internal links + are always validated across the whole repository, because deleting, renaming, + or moving a target breaks references in files the change never touched. + External links are fetched once per unique URL and may be scoped to changed + files. Checks tracked and untracked, nonignored files so local validation does + not require staging. .PARAMETER Path One or more files or directories to scan. Directories are searched @@ -22,13 +25,30 @@ .PARAMETER Quiet Suppress non-error output from markdown-link-check. +.PARAMETER ChangedFilesOnly + Restrict external-link validation to Markdown files changed relative to + BaseBranch. Internal links are still validated repository-wide. External links + in unchanged files are reported as skipped rather than checked. + +.PARAMETER BaseBranch + Branch reference used by -ChangedFilesOnly to compute the changed-file set. + +.PARAMETER ThrottleLimit + Maximum number of markdown-link-check processes run concurrently, and the + minimum number of batches targets are distributed across. Additional batches + are created when a batch would exceed the platform command-line length limit. + .EXAMPLE - # Validate all markdown files in default paths + # Validate all markdown links across the repository ./Markdown-Link-Check.ps1 .EXAMPLE # Validate specific path with verbose output ./Markdown-Link-Check.ps1 -Path ".github" -Quiet:$false + +.EXAMPLE + # Validate internal links repository-wide and external links only in changed files + ./Markdown-Link-Check.ps1 -ChangedFilesOnly #> [CmdletBinding()] @@ -41,7 +61,14 @@ param( [string]$ConfigPath = (Join-Path -Path $PSScriptRoot -ChildPath 'markdown-link-check.config.json'), - [switch]$Quiet + [switch]$Quiet, + + [switch]$ChangedFilesOnly, + + [string]$BaseBranch = 'origin/main', + + [ValidateRange(1, 32)] + [int]$ThrottleLimit = 8 ) $ErrorActionPreference = 'Stop' @@ -63,11 +90,21 @@ function Get-MarkdownTarget { .PARAMETER InputPath Files or directories that may contain Markdown content. + .PARAMETER ChangedFilesOnly + Restrict the result to Markdown files changed relative to BaseBranch. + + .PARAMETER BaseBranch + Branch reference used to compute the changed-file set. + .OUTPUTS System.String[] #> param( - [string[]]$InputPath + [string[]]$InputPath, + + [switch]$ChangedFilesOnly, + + [string]$BaseBranch = 'origin/main' ) $targets = @() @@ -75,6 +112,9 @@ function Get-MarkdownTarget { if ($LASTEXITCODE -ne 0) { Write-Warning "Not in a git repository, falling back to file system search" + if ($ChangedFilesOnly) { + Write-Warning "Changed-files-only mode requires a git repository; scanning all Markdown files" + } # Fallback to original implementation if not in git repo foreach ($item in $InputPath) { if ([string]::IsNullOrWhiteSpace($item)) { @@ -104,6 +144,17 @@ function Get-MarkdownTarget { Write-Verbose "Searching for tracked and untracked, nonignored markdown files..." Write-Verbose "Repository root: $repoRoot" + # Repo-relative changed-file allowlist; $null means "no changed-file filtering". + $changedSet = $null + if ($ChangedFilesOnly) { + $changedSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($changed in @(Get-ChangedFilesFromGit -BaseBranch $BaseBranch -FileExtensions @('*.md'))) { + [void]$changedSet.Add(($changed -replace '\\', '/')) + } + + Write-Verbose "Changed markdown files detected against ${BaseBranch}: $($changedSet.Count)" + } + # Git-aware implementation foreach ($item in $InputPath) { if ([string]::IsNullOrWhiteSpace($item)) { @@ -121,7 +172,9 @@ function Get-MarkdownTarget { } if ($listed -and $item -like "*.md") { - $targets += $absolutePath + if ($null -eq $changedSet -or $changedSet.Contains($relativePath)) { + $targets += $absolutePath + } } elseif (-not $listed) { Write-Warning "File is ignored by git: $item" @@ -148,7 +201,8 @@ function Get-MarkdownTarget { Where-Object { $prefix -eq '' -or $_.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase) } | Where-Object { $_ -notlike 'scripts/tests/*fixtures/*' } | # Generated output; 490 of its 504 markdown files symlink to sources already checked. - Where-Object { $_ -notlike 'plugins/*' } + Where-Object { $_ -notlike 'plugins/*' } | + Where-Object { $null -eq $changedSet -or $changedSet.Contains($_) } if ($trackedFiles) { foreach ($file in $trackedFiles) { @@ -205,150 +259,912 @@ function Get-RelativePrefix { return $normalized } +function Split-MarkdownTargetBatch { + <# + .SYNOPSIS + Splits Markdown targets into deterministic balanced batches. + + .DESCRIPTION + Sorts the supplied target paths and distributes them across no more than + the configured throttle limit. Each returned object preserves its file + array as one pipeline item for parallel processing. + + When an argument-length budget is supplied, the throttle limit becomes a + minimum batch count rather than a maximum: a count-balanced batch whose + arguments would not fit is split into contiguous sub-batches so every + emitted batch stays within the budget. Callers that omit the budget keep + the count-only behavior. + + .PARAMETER Target + Repository-relative Markdown file paths to batch. + + .PARAMETER ThrottleLimit + Minimum number of batches to create, and the maximum when no + argument-length budget applies. + + .PARAMETER ArgumentLengthBudget + Total command-line length available to one invocation. Zero disables + length-based splitting. + + .PARAMETER ReservedLength + Length already consumed by the executable path and the fixed arguments + every invocation carries, which is unavailable to target arguments. + + .OUTPUTS + System.Management.Automation.PSCustomObject + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [string[]]$Target, + + [ValidateRange(1, 32)] + [int]$ThrottleLimit, + + [ValidateRange(0, [int]::MaxValue)] + [int]$ArgumentLengthBudget = 0, + + [ValidateRange(0, [int]::MaxValue)] + [int]$ReservedLength = 0 + ) + + $sortedTargets = @($Target | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object) + if ($sortedTargets.Count -eq 0) { + return + } + + $batchCount = [Math]::Min($sortedTargets.Count, $ThrottleLimit) + $baseSize = [Math]::Floor($sortedTargets.Count / $batchCount) + $remainder = $sortedTargets.Count % $batchCount + $offset = 0 + $countBalancedBatches = @() + + for ($index = 0; $index -lt $batchCount; $index++) { + $batchSize = $baseSize + $(if ($index -lt $remainder) { 1 } else { 0 }) + $countBalancedBatches += , @($sortedTargets[$offset..($offset + $batchSize - 1)]) + $offset += $batchSize + } + + $emitIndex = 0 + if ($ArgumentLengthBudget -le 0) { + foreach ($files in $countBalancedBatches) { + [pscustomobject]@{ + Index = $emitIndex + Files = @($files) + } + $emitIndex++ + } + return + } + + $available = $ArgumentLengthBudget - $ReservedLength + if ($available -le 0) { + throw "The reserved command-line length $ReservedLength leaves no room within the budget $ArgumentLengthBudget." + } + + # Balanced-by-count batches are not balanced by length when path lengths vary, + # so each batch is packed greedily against the budget rather than trusting an + # average. Every argument also costs a separating space and the two quote + # characters a shell adds for values containing spaces. + foreach ($files in $countBalancedBatches) { + $currentFiles = @() + $currentLength = 0 + + foreach ($file in $files) { + $cost = $file.Length + 3 + if ($cost -gt $available) { + throw "Markdown target '$file' alone exceeds the available command-line length $available." + } + + if ($currentFiles.Count -gt 0 -and ($currentLength + $cost) -gt $available) { + [pscustomobject]@{ + Index = $emitIndex + Files = @($currentFiles) + } + $emitIndex++ + $currentFiles = @() + $currentLength = 0 + } + + $currentFiles += $file + $currentLength += $cost + } + + if ($currentFiles.Count -gt 0) { + [pscustomobject]@{ + Index = $emitIndex + Files = @($currentFiles) + } + $emitIndex++ + } + } +} + +function Get-MarkdownBatchLengthBudget { + <# + .SYNOPSIS + Computes the command-line length budget for source-pass batches. + + .DESCRIPTION + Returns the platform command-line ceiling and the length reserved by the + executable path and the fixed arguments every source invocation carries, + so batching can bound only the target arguments it appends. + + On Windows the CLI resolves to a .cmd shim, so the process is dispatched + through the command interpreter and the 8191-character interpreter limit + governs rather than the 32767-character CreateProcess limit. The reserved + allowance therefore includes the interpreter wrapper and per-argument + quoting overhead. + + .PARAMETER Cli + Path to the markdown-link-check executable. + + .PARAMETER BaseArgument + Arguments applied to every invocation before target files. + + .PARAMETER ReportPath + A representative JUnit report path carried by the reporter arguments. + + .OUTPUTS + System.Management.Automation.PSCustomObject + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [string]$Cli, + + [string[]]$BaseArgument, + + [string]$ReportPath + ) + + $budget = if ($IsWindows) { 8191 } else { 131072 } + + $fixedArguments = @($BaseArgument) + @( + '--reporters', + 'default,junit', + '--junit-output', + $ReportPath + ) + + $reserved = $Cli.Length + 3 + foreach ($argument in $fixedArguments) { + $reserved += ([string]$argument).Length + 3 + } + + if ($IsWindows) { + # PowerShell dispatches a .cmd shim through the command interpreter. + $reserved += 'cmd.exe /c ""'.Length + } + + return [pscustomobject]@{ + Budget = $budget + Reserved = $reserved + } +} + +function Test-MarkdownExternalScope { + <# + .SYNOPSIS + Tests whether a source file belongs to the external-link scope. + + .DESCRIPTION + Compares a relative source path against the external-link allowlist. An + absent allowlist means every file is in scope; an empty allowlist means + no file is. Those two states have opposite meanings, so callers must pass + $null rather than an empty set when no scoping applies. + + Both sides are normalized to forward slashes because relative source + paths carry the platform separator while the allowlist is normalized when + it is built. + + .PARAMETER RelativePath + Repository-relative source path as reported by the source pass. + + .PARAMETER Scope + Allowlist of normalized repository-relative paths, or $null for all files. + + .OUTPUTS + System.Boolean + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [string]$RelativePath, + + [AllowNull()] + [System.Collections.Generic.HashSet[string]]$Scope + ) + + if ($null -eq $Scope) { + return $true + } + + return $Scope.Contains(($RelativePath -replace '\\', '/')) +} + +function ConvertFrom-MarkdownLinkCheckReport { + <# + .SYNOPSIS + Converts a batched JUnit report into per-file link-check results. + + .DESCRIPTION + Matches each JUnit suite to an expected file through its full file + property. Selective attribution is trusted only when suites and expected + files form a one-to-one set; otherwise every expected file fails closed + while links from suites that could be identified remain available. + + .PARAMETER ExpectedFile + Repository-relative files supplied to one CLI invocation. + + .PARAMETER ReportContent + JUnit XML emitted by markdown-link-check. + + .PARAMETER ExitCode + Aggregate exit code from the batched CLI invocation. + + .OUTPUTS + System.Management.Automation.PSCustomObject + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [string[]]$ExpectedFile, + + [AllowNull()] + [AllowEmptyString()] + [string]$ReportContent, + + [int]$ExitCode = 0 + ) + + $expectedFiles = @($ExpectedFile | Sort-Object) + if ($expectedFiles.Count -eq 0) { + return + } + + $pathComparer = if ($IsWindows) { + [System.StringComparer]::OrdinalIgnoreCase + } + else { + [System.StringComparer]::Ordinal + } + $expectedByNormalizedPath = [System.Collections.Generic.Dictionary[string, string]]::new($pathComparer) + $linksByFile = @{} + $failedByFile = @{} + $reportTrusted = $true + $reportError = $null + + foreach ($expected in $expectedFiles) { + $normalized = $expected -replace '\\', '/' + if ($expectedByNormalizedPath.ContainsKey($normalized)) { + $reportTrusted = $false + } + else { + $expectedByNormalizedPath.Add($normalized, $expected) + } + $linksByFile[$expected] = @() + $failedByFile[$expected] = $false + } + + try { + if ([string]::IsNullOrWhiteSpace($ReportContent)) { + throw 'The JUnit report was not created.' + } + + [xml]$xml = $ReportContent + $seenFiles = [System.Collections.Generic.HashSet[string]]::new($pathComparer) + foreach ($testSuite in @($xml.testsuites.testsuite)) { + $fileProperties = @($testSuite.properties.property | Where-Object { $_.name -eq 'file' }) + if ($fileProperties.Count -ne 1 -or [string]::IsNullOrWhiteSpace($fileProperties[0].value)) { + $reportTrusted = $false + continue + } + + $normalizedSuiteFile = ([string]$fileProperties[0].value) -replace '\\', '/' + if (-not $expectedByNormalizedPath.ContainsKey($normalizedSuiteFile) -or -not $seenFiles.Add($normalizedSuiteFile)) { + $reportTrusted = $false + continue + } + + $expected = $expectedByNormalizedPath[$normalizedSuiteFile] + $links = foreach ($testCase in @($testSuite.testcase)) { + if ($null -eq $testCase) { + continue + } + + $properties = @($testCase.properties.property) + [pscustomobject]@{ + Url = ($properties | Where-Object { $_.name -eq 'url' } | Select-Object -First 1).value + Status = ($properties | Where-Object { $_.name -eq 'status' } | Select-Object -First 1).value + StatusCode = ($properties | Where-Object { $_.name -eq 'statusCode' } | Select-Object -First 1).value + } + } + $linksByFile[$expected] = @($links) + $failedByFile[$expected] = ( + [int]$testSuite.failures -gt 0 -or + [int]$testSuite.errors -gt 0 -or + @($links | Where-Object { $_.Status -in @('dead', 'error') }).Count -gt 0 + ) + } + + if ($seenFiles.Count -ne $expectedByNormalizedPath.Count) { + $reportTrusted = $false + } + } + catch { + $reportTrusted = $false + $reportError = $_.Exception.Message + } + + $hasReportedFailure = @($failedByFile.Values | Where-Object { $_ }).Count -gt 0 + $unexplainedExit = $reportTrusted -and $ExitCode -ne 0 -and -not $hasReportedFailure + + foreach ($expected in $expectedFiles) { + [pscustomobject]@{ + File = $expected + Links = @($linksByFile[$expected]) + Failed = (-not $reportTrusted) -or $unexplainedExit -or $failedByFile[$expected] + ParseFailed = -not $reportTrusted + ReportError = $reportError + } + } +} + +function Invoke-MarkdownLinkCheckBatch { + <# + .SYNOPSIS + Runs prepared markdown-link-check batches with bounded parallelism. + + .DESCRIPTION + Invokes the configured CLI once for each prepared batch, using the + caller-provided JUnit report path and returning raw ordered evidence for + later source or aggregate conversion. The caller owns workspace cleanup. + + .PARAMETER Batch + Batch objects containing Index, Files, and ReportPath properties. + + .PARAMETER Cli + Path to the markdown-link-check executable. + + .PARAMETER BaseArgument + Arguments applied to every CLI invocation before target files. + + .PARAMETER RootPath + Working directory used by each parallel worker. + + .PARAMETER ThrottleLimit + Maximum number of CLI processes to run concurrently. + + .OUTPUTS + System.Management.Automation.PSCustomObject + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [object[]]$Batch, + + [string]$Cli, + + [string[]]$BaseArgument, + + [string]$RootPath, + + [ValidateRange(1, 32)] + [int]$ThrottleLimit + ) + + return @($Batch | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel { + Set-Location -LiteralPath $using:RootPath + $currentBatch = $_ + $output = $null + $exitCode = 0 + $reportContent = $null + $invocationError = $null + try { + $commandArgs = $using:BaseArgument + @($currentBatch.Files) + @( + '--reporters', + 'default,junit', + '--junit-output', + $currentBatch.ReportPath + ) + + $output = & $using:Cli @commandArgs 2>&1 + $exitCode = $LASTEXITCODE + + if (Test-Path -LiteralPath $currentBatch.ReportPath) { + $reportContent = Get-Content -LiteralPath $currentBatch.ReportPath -Raw -Encoding utf8 + } + } + catch { + $invocationError = $_.Exception.Message + } + + [pscustomobject]@{ + Index = $currentBatch.Index + Files = @($currentBatch.Files) + ExpectedUrls = @($currentBatch.ExpectedUrls) + ExitCode = $exitCode + Output = $output + ReportContent = $reportContent + InvocationError = $invocationError + } + }) +} + +function ConvertFrom-MarkdownExternalLinkReport { + <# + .SYNOPSIS + Validates one synthetic external-link JUnit report. + + .DESCRIPTION + Trusts an aggregate report only when its suite is attributed to the + expected synthetic file and every expected URL has one testcase with + one supported status and at most one status code. + + .PARAMETER ExpectedFile + Synthetic Markdown file supplied to the CLI invocation. + + .PARAMETER ExpectedUrl + Exact external URLs assigned to the synthetic file. + + .PARAMETER ReportContent + JUnit XML emitted by markdown-link-check. + + .PARAMETER ExitCode + Exit code from the aggregate CLI invocation. + + .OUTPUTS + System.Management.Automation.PSCustomObject + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [string]$ExpectedFile, + + [string[]]$ExpectedUrl, + + [AllowNull()] + [AllowEmptyString()] + [string]$ReportContent, + + [int]$ExitCode = 0 + ) + + $links = @() + $reportError = $null + try { + if ([string]::IsNullOrWhiteSpace($ReportContent)) { + throw 'The JUnit report was not created.' + } + + $pathComparer = if ($IsWindows) { + [System.StringComparer]::OrdinalIgnoreCase + } + else { + [System.StringComparer]::Ordinal + } + $expectedUrls = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($url in $ExpectedUrl) { + if ([string]::IsNullOrWhiteSpace($url) -or -not $expectedUrls.Add($url)) { + throw 'Expected aggregate URLs must be unique and nonblank.' + } + } + + [xml]$xml = $ReportContent + $testSuites = @($xml.testsuites.testsuite) + if ($testSuites.Count -ne 1) { + throw 'The aggregate report must contain exactly one test suite.' + } + + $testSuite = $testSuites[0] + $fileProperties = @($testSuite.properties.property | Where-Object { $_.name -eq 'file' }) + if ($fileProperties.Count -ne 1 -or [string]::IsNullOrWhiteSpace($fileProperties[0].value)) { + throw 'The aggregate suite must contain exactly one nonblank file property.' + } + + $reportedFile = ([string]$fileProperties[0].value) -replace '\\', '/' + $normalizedExpectedFile = $ExpectedFile -replace '\\', '/' + if (-not $pathComparer.Equals($reportedFile, $normalizedExpectedFile)) { + throw "The aggregate suite was attributed to an unexpected file: $reportedFile" + } + + $supportedStatuses = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($status in @('alive', 'dead', 'ignored', 'error')) { + [void]$supportedStatuses.Add($status) + } + $seenUrls = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($testCase in @($testSuite.testcase)) { + if ($null -eq $testCase) { + continue + } + + $properties = @($testCase.properties.property) + $urlProperties = @($properties | Where-Object { $_.name -eq 'url' }) + $statusProperties = @($properties | Where-Object { $_.name -eq 'status' }) + $statusCodeProperties = @($properties | Where-Object { $_.name -eq 'statusCode' }) + if ($urlProperties.Count -ne 1 -or [string]::IsNullOrWhiteSpace($urlProperties[0].value)) { + throw 'Each aggregate testcase must contain exactly one nonblank url property.' + } + if ($statusProperties.Count -ne 1 -or -not $supportedStatuses.Contains([string]$statusProperties[0].value)) { + throw 'Each aggregate testcase must contain exactly one supported status property.' + } + if ($statusCodeProperties.Count -gt 1) { + throw 'Each aggregate testcase may contain at most one statusCode property.' + } + + $url = [string]$urlProperties[0].value + if (-not $expectedUrls.Contains($url) -or -not $seenUrls.Add($url)) { + throw "The aggregate report contained a duplicate or unexpected URL: $url" + } + + $links += [pscustomobject]@{ + Url = $url + Status = [string]$statusProperties[0].value + StatusCode = if ($statusCodeProperties.Count -eq 1) { + [string]$statusCodeProperties[0].value + } + else { + $null + } + } + } + + if ($seenUrls.Count -ne $expectedUrls.Count) { + throw 'The aggregate report did not contain one result for every expected URL.' + } + + $hasReportedFailure = @($links | Where-Object { $_.Status -in @('dead', 'error') }).Count -gt 0 + if ($ExitCode -ne 0 -and -not $hasReportedFailure) { + throw "The aggregate CLI exited with code $ExitCode without a dead or error result." + } + } + catch { + $links = @() + $reportError = $_.Exception.Message + } + + return [pscustomobject]@{ + Trusted = $null -eq $reportError + Links = @($links) + ReportError = $reportError + } +} + function Invoke-MarkdownLinkCheck { [CmdletBinding()] [OutputType([void])] param( [string[]]$Path, [string]$ConfigPath, - [switch]$Quiet + [switch]$Quiet, + [switch]$ChangedFilesOnly, + [string]$BaseBranch = 'origin/main', + [int]$ThrottleLimit = 8 ) $scriptRootParent = Split-Path -Path $PSScriptRoot -Parent $repoRootPath = Split-Path -Path $scriptRootParent -Parent $repoRoot = Resolve-Path -LiteralPath $repoRootPath $config = Resolve-Path -LiteralPath $ConfigPath -ErrorAction Stop + + # Internal links are validated repository-wide on every run, because deleting, + # renaming, or moving a target breaks references in files the change never + # touched. External links decay on their own schedule instead, so they are + # scoped to changed Markdown and the weekly full sweep owns the rest. $filesToCheck = @(Get-MarkdownTarget -InputPath $Path) if (-not $filesToCheck -or @($filesToCheck).Count -eq 0) { throw 'No markdown files were found to validate.' } - $cli = Join-Path -Path $repoRoot.Path -ChildPath 'node_modules/.bin/markdown-link-check' - if ($IsWindows) { - $cli += '.cmd' + # $null means "no external scoping"; an empty set means "no file is in scope". + # Conflating the two would silently disable all external checking. + $externalScope = $null + if ($ChangedFilesOnly) { + $externalScope = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $changedTargets = @(Get-MarkdownTarget -InputPath $Path -ChangedFilesOnly -BaseBranch $BaseBranch) + foreach ($changedTarget in $changedTargets) { + $changedRelative = [System.IO.Path]::GetRelativePath($repoRoot.Path, $changedTarget) -replace '\\', '/' + [void]$externalScope.Add($changedRelative) + } + + if ($externalScope.Count -eq 0) { + Write-Output 'No changed markdown files; external link validation was skipped.' + } } - if (-not (Test-Path -LiteralPath $cli)) { - throw 'markdown-link-check is not installed. Run "npm install --save-dev markdown-link-check" first.' + $cliOverride = Get-Variable -Name MarkdownLinkCheckCliOverride -Scope Script -ValueOnly -ErrorAction SilentlyContinue + if ($cliOverride) { + $cli = [string]$cliOverride + } + else { + $cli = Join-Path -Path $repoRoot.Path -ChildPath 'node_modules/.bin/markdown-link-check' + if ($IsWindows) { + $cli += '.cmd' + } } - $baseArguments = @('-c', $config.Path) - if ($Quiet) { - $baseArguments += '-q' + if (-not (Test-Path -LiteralPath $cli)) { + throw 'markdown-link-check is not installed. Run "npm install --save-dev markdown-link-check" first.' } $failedFiles = @() $brokenLinks = @() $totalLinks = 0 + $skippedLinks = 0 $totalFiles = $filesToCheck.Count + $rootPath = $repoRoot.Path - Push-Location $repoRoot.Path + $taskWorkspace = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'markdown-link-check-{0}' -f [System.Guid]::NewGuid().ToString('N') + ) + New-Item -ItemType Directory -Path $taskWorkspace -Force | Out-Null try { - foreach ($file in $filesToCheck) { - $absolute = Resolve-Path -LiteralPath $file - $relative = [System.IO.Path]::GetRelativePath($repoRoot.Path, $absolute) - Write-Output "Checking $relative" + $sourceConfig = Get-Content -LiteralPath $config.Path -Raw -Encoding utf8 | ConvertFrom-Json -AsHashtable + $existingIgnorePatterns = @() + if ( + $sourceConfig.ContainsKey('ignorePatterns') -and + $null -ne $sourceConfig['ignorePatterns'] + ) { + $existingIgnorePatterns += @($sourceConfig['ignorePatterns']) + } + $sourceConfig['ignorePatterns'] = $existingIgnorePatterns + @( + @{ pattern = '^[Hh][Tt][Tt][Pp][Ss]?://' } + ) + $sourceConfigPath = Join-Path $taskWorkspace 'source-config.json' + $sourceConfig | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $sourceConfigPath -Encoding utf8 + + $sourceArguments = @('-c', $sourceConfigPath) + if ($Quiet) { + $sourceArguments += '-q' + } + + $relativeTargets = @($filesToCheck | ForEach-Object { + [System.IO.Path]::GetRelativePath($rootPath, (Resolve-Path -LiteralPath $_)) + }) + $lengthBudget = Get-MarkdownBatchLengthBudget ` + -Cli $cli ` + -BaseArgument $sourceArguments ` + -ReportPath (Join-Path $taskWorkspace 'source-00.xml') + $targetBatches = @(Split-MarkdownTargetBatch ` + -Target $relativeTargets ` + -ThrottleLimit $ThrottleLimit ` + -ArgumentLengthBudget $lengthBudget.Budget ` + -ReservedLength $lengthBudget.Reserved | ForEach-Object { + [pscustomobject]@{ + Index = $_.Index + Files = @($_.Files) + ReportPath = Join-Path $taskWorkspace ('source-{0:D2}.xml' -f $_.Index) + } + }) + $batchResults = @(Invoke-MarkdownLinkCheckBatch ` + -Batch $targetBatches ` + -Cli $cli ` + -BaseArgument $sourceArguments ` + -RootPath $rootPath ` + -ThrottleLimit $ThrottleLimit) + + $fileResults = @() + foreach ($batchResult in ($batchResults | Sort-Object -Property Index)) { + if (($VerbosePreference -eq 'Continue' -or $batchResult.ExitCode -ne 0) -and $null -ne $batchResult.Output) { + Write-Host $batchResult.Output + } - # Create temp file for XML output - $xmlFile = [System.IO.Path]::GetTempFileName() + '.xml' - try { - $commandArgs = $baseArguments + @($relative, '--reporters', 'default,junit', '--junit-output', $xmlFile) + $convertedResults = @(ConvertFrom-MarkdownLinkCheckReport ` + -ExpectedFile $batchResult.Files ` + -ReportContent $batchResult.ReportContent ` + -ExitCode $batchResult.ExitCode) + if (@($convertedResults | Where-Object ParseFailed).Count -gt 0) { + $reason = if ($batchResult.InvocationError) { + $batchResult.InvocationError + } + elseif ($convertedResults[0].ReportError) { + $convertedResults[0].ReportError + } + else { + 'The report did not contain one unique suite for every expected file.' + } + Write-Warning "Failed to parse or attribute XML output for batch $($batchResult.Index): $reason" + } + $fileResults += $convertedResults + } - # Run markdown-link-check with XML output and capture output - $output = & $cli @commandArgs 2>&1 - $exitCode = $LASTEXITCODE + $externalUrls = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($fileResult in @($fileResults | Where-Object { -not $_.ParseFailed })) { + if (-not (Test-MarkdownExternalScope -RelativePath $fileResult.File -Scope $externalScope)) { + continue + } - # Display output if verbose mode or if there were errors - if ($VerbosePreference -eq 'Continue' -or $exitCode -ne 0) { - Write-Host $output + foreach ($link in $fileResult.Links) { + if ([regex]::IsMatch([string]$link.Url, '^[Hh][Tt][Tt][Pp][Ss]?://')) { + [void]$externalUrls.Add([string]$link.Url) } + } + } - # Parse XML output - if (Test-Path $xmlFile) { - [xml]$xml = Get-Content $xmlFile -Raw -Encoding utf8 - - foreach ($testsuite in $xml.testsuites.testsuite) { - foreach ($testcase in $testsuite.testcase) { - $totalLinks++ - - # Extract properties - $url = ($testcase.properties.property | Where-Object { $_.name -eq 'url' }).value - $status = ($testcase.properties.property | Where-Object { $_.name -eq 'status' }).value - $statusCode = ($testcase.properties.property | Where-Object { $_.name -eq 'statusCode' }).value - - # Display human-readable output if not quiet - if (-not $Quiet) { - if ($status -eq 'alive') { - Write-Host " ✓ $url" -ForegroundColor Green - } - elseif ($status -eq 'ignored') { - Write-Host " / $url (ignored)" -ForegroundColor Yellow - } - elseif ($status -eq 'dead') { - Write-Host " ✖ $url → Status: $statusCode" -ForegroundColor Red - } - } - - # Process broken links - if ($status -eq 'dead') { - $brokenLinks += @{ - File = $relative - Link = $url - Status = "$statusCode" - } - - Write-CIAnnotation -Message "Broken link: $url (Status: $statusCode)" -Level Error -File $relative - } - } + $externalResultsByUrl = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + $externalErrorsByUrl = [System.Collections.Generic.Dictionary[string, string]]::new( + [System.StringComparer]::Ordinal + ) + if ($externalUrls.Count -gt 0) { + $urlBatches = @(Split-MarkdownTargetBatch -Target @($externalUrls) -ThrottleLimit $ThrottleLimit) + $aggregateBatches = @($urlBatches | ForEach-Object { + $aggregatePath = Join-Path $taskWorkspace ('external-links-{0:D2}.md' -f $_.Index) + $content = foreach ($url in $_.Files) { + 'link' -f [System.Net.WebUtility]::HtmlEncode($url) + } + $content | Set-Content -LiteralPath $aggregatePath -Encoding utf8 + [pscustomobject]@{ + Index = $_.Index + Files = @($aggregatePath) + ExpectedUrls = @($_.Files) + ReportPath = Join-Path $taskWorkspace ('external-{0:D2}.xml' -f $_.Index) + } + }) + $aggregateArguments = @('-c', $config.Path, '-q') + $aggregateResults = @(Invoke-MarkdownLinkCheckBatch ` + -Batch $aggregateBatches ` + -Cli $cli ` + -BaseArgument $aggregateArguments ` + -RootPath $rootPath ` + -ThrottleLimit $ThrottleLimit) + + foreach ($aggregateResult in ($aggregateResults | Sort-Object -Property Index)) { + $convertedAggregate = ConvertFrom-MarkdownExternalLinkReport ` + -ExpectedFile $aggregateResult.Files[0] ` + -ExpectedUrl $aggregateResult.ExpectedUrls ` + -ReportContent $aggregateResult.ReportContent ` + -ExitCode $aggregateResult.ExitCode + if ($convertedAggregate.Trusted) { + foreach ($link in $convertedAggregate.Links) { + $externalResultsByUrl[$link.Url] = $link } + continue } - if ($exitCode -ne 0) { - $failedFiles += $relative + $reason = if ($aggregateResult.InvocationError) { + $aggregateResult.InvocationError + } + else { + $convertedAggregate.ReportError + } + Write-Warning "Failed to trust external-link batch $($aggregateResult.Index): $reason" + foreach ($url in $aggregateResult.ExpectedUrls) { + $externalErrorsByUrl[$url] = $reason } } - catch { - Write-Warning "Failed to parse XML output for $relative : $_" - if ($failedFiles -notcontains $relative) { - $failedFiles += $relative + } + + foreach ($fileResult in @($fileResults | Where-Object { -not $_.ParseFailed })) { + $inExternalScope = Test-MarkdownExternalScope -RelativePath $fileResult.File -Scope $externalScope + $replayedLinks = foreach ($link in $fileResult.Links) { + $url = [string]$link.Url + if (-not [regex]::IsMatch($url, '^[Hh][Tt][Tt][Pp][Ss]?://')) { + $link + continue + } + + if (-not $inExternalScope) { + # The source pass ignores every external link by configuration, + # so the inbound placeholder already reads 'ignored'. A new link + # is built instead of passing it through, keeping "excluded by + # configuration" distinct from "excluded by this run's scope". + [pscustomobject]@{ + Url = $url + Status = 'skipped' + StatusCode = $null + } + continue } + + if ($externalErrorsByUrl.ContainsKey($url)) { + $fileResult.Failed = $true + $fileResult.ParseFailed = $true + if ([string]::IsNullOrWhiteSpace($fileResult.ReportError)) { + $fileResult.ReportError = $externalErrorsByUrl[$url] + } + $link + continue + } + + if (-not $externalResultsByUrl.ContainsKey($url)) { + $fileResult.Failed = $true + $fileResult.ParseFailed = $true + $fileResult.ReportError = "No trusted aggregate result was available for $url" + $link + continue + } + + $externalResult = $externalResultsByUrl[$url] + [pscustomobject]@{ + Url = $url + Status = $externalResult.Status + StatusCode = $externalResult.StatusCode + } + } + $fileResult.Links = @($replayedLinks) + if (@($fileResult.Links | Where-Object { $_.Status -in @('dead', 'error') }).Count -gt 0) { + $fileResult.Failed = $true } - finally { - if (Test-Path $xmlFile) { - Remove-Item $xmlFile -Force + } + + foreach ($fileResult in ($fileResults | Sort-Object -Property File)) { + $relative = $fileResult.File + Write-Output "Checking $relative" + + foreach ($link in $fileResult.Links) { + # A skipped link was deliberately left unchecked by this run's + # scope, so counting it would overstate what was actually resolved. + # It is counted separately instead, because a consumer reading only + # the totals would otherwise see an unexplained shortfall. + if ($link.Status -eq 'skipped') { + $skippedLinks++ + } + else { + $totalLinks++ + } + + # Display human-readable output if not quiet + if (-not $Quiet) { + if ($link.Status -eq 'alive') { + Write-Host " ✓ $($link.Url)" -ForegroundColor Green + } + elseif ($link.Status -eq 'ignored') { + Write-Host " / $($link.Url) (ignored)" -ForegroundColor Yellow + } + elseif ($link.Status -eq 'skipped') { + Write-Host " - $($link.Url) (skipped: outside the changed-file scope)" -ForegroundColor DarkGray + } + elseif ($link.Status -eq 'dead') { + Write-Host " ✖ $($link.Url) → Status: $($link.StatusCode)" -ForegroundColor Red + } } + + # Process broken links + if ($link.Status -eq 'dead') { + $brokenLinks += @{ + File = $relative + Link = $link.Url + Status = "$($link.StatusCode)" + } + + Write-CIAnnotation -Message "Broken link: $($link.Url) (Status: $($link.StatusCode))" -Level Error -File $relative + } + } + + if ($fileResult.Failed -and $failedFiles -notcontains $relative) { + $failedFiles += $relative } } - } - finally { - Pop-Location - } - # Create logs directory and export results - $logsDir = Join-Path -Path $repoRoot.Path -ChildPath 'logs' - if (-not (Test-Path $logsDir)) { - New-Item -ItemType Directory -Path $logsDir -Force | Out-Null - } + # Create logs directory and export results + $logsDir = Join-Path -Path $repoRoot.Path -ChildPath 'logs' + if (-not (Test-Path $logsDir)) { + New-Item -ItemType Directory -Path $logsDir -Force | Out-Null + } - $results = @{ + $results = @{ Timestamp = Get-StandardTimestamp script = 'markdown-link-check' summary = @{ total_files = $totalFiles files_with_broken_links = $failedFiles.Count total_links_checked = $totalLinks + skipped_links = $skippedLinks total_broken_links = $brokenLinks.Count } broken_links = $brokenLinks } - $resultsPath = Join-Path -Path $logsDir -ChildPath 'markdown-link-check-results.json' - $results | ConvertTo-Json -Depth 10 | Set-Content -Path $resultsPath -Encoding UTF8 + $resultsPath = Join-Path -Path $logsDir -ChildPath 'markdown-link-check-results.json' + $results | ConvertTo-Json -Depth 10 | Set-Content -Path $resultsPath -Encoding UTF8 # Generate GitHub step summary - if ($failedFiles.Count -gt 0) { - $summaryContent = @" + if ($failedFiles.Count -gt 0) { + $summaryContent = @" ## ❌ Markdown Link Check Failed **Files with broken links:** $($failedFiles.Count) / $totalFiles @@ -360,17 +1176,17 @@ function Invoke-MarkdownLinkCheck { |------|-------------| "@ - foreach ($link in $brokenLinks) { - $safeFile = if ((Get-CIPlatform) -eq 'azdo') { - ConvertTo-AzureDevOpsEscaped -Value $link.File - } else { $link.File } - $safeLink = if ((Get-CIPlatform) -eq 'azdo') { - ConvertTo-AzureDevOpsEscaped -Value $link.Link - } else { $link.Link } - $summaryContent += "`n| ``$safeFile`` | ``$safeLink`` |" - } + foreach ($link in $brokenLinks) { + $safeFile = if ((Get-CIPlatform) -eq 'azdo') { + ConvertTo-AzureDevOpsEscaped -Value $link.File + } else { $link.File } + $safeLink = if ((Get-CIPlatform) -eq 'azdo') { + ConvertTo-AzureDevOpsEscaped -Value $link.Link + } else { $link.Link } + $summaryContent += "`n| ``$safeFile`` | ``$safeLink`` |" + } - $summaryContent += @" + $summaryContent += @" ### How to Fix @@ -382,13 +1198,13 @@ function Invoke-MarkdownLinkCheck { For more information, see the [markdown-link-check documentation](https://github.com/tcort/markdown-link-check). "@ - Write-CIStepSummary -Content $summaryContent - Set-CIEnv -Name "MARKDOWN_LINK_CHECK_FAILED" -Value "true" + Write-CIStepSummary -Content $summaryContent + Set-CIEnv -Name "MARKDOWN_LINK_CHECK_FAILED" -Value "true" - throw ("markdown-link-check reported failures for: {0}" -f ($failedFiles -join ', ')) - } - else { - $summaryContent = @" + throw ("markdown-link-check reported failures for: {0}" -f ($failedFiles -join ', ')) + } + else { + $summaryContent = @" ## ✅ Markdown Link Check Passed **Files checked:** $totalFiles @@ -398,15 +1214,20 @@ For more information, see the [markdown-link-check documentation](https://github Great job! All markdown links are valid. 🎉 "@ - Write-CIStepSummary -Content $summaryContent - Write-Output 'markdown-link-check completed successfully.' + Write-CIStepSummary -Content $summaryContent + Write-Output 'markdown-link-check completed successfully.' + } + } + finally { + Remove-Item -LiteralPath $taskWorkspace -Recurse -Force -ErrorAction SilentlyContinue } } #region Main Execution if ($MyInvocation.InvocationName -ne '.') { try { - Invoke-MarkdownLinkCheck -Path $Path -ConfigPath $ConfigPath -Quiet:$Quiet + Invoke-MarkdownLinkCheck -Path $Path -ConfigPath $ConfigPath -Quiet:$Quiet ` + -ChangedFilesOnly:$ChangedFilesOnly -BaseBranch $BaseBranch -ThrottleLimit $ThrottleLimit exit 0 } catch { diff --git a/scripts/linting/README.md b/scripts/linting/README.md index 4d422ac65..e2f9154cc 100644 --- a/scripts/linting/README.md +++ b/scripts/linting/README.md @@ -235,6 +235,8 @@ Purpose: Detect broken links before deployment. ##### Features * Discovers tracked and untracked, non-ignored Markdown files so local validation does not require staging +* Restricts the scan to files changed against a base branch with `-ChangedFilesOnly` and `-BaseBranch` +* Checks files concurrently, bounded by `-ThrottleLimit` (default 8) * Checks internal and external links * Configurable via `markdown-link-check.config.json` * Retries failed links @@ -257,6 +259,7 @@ Purpose: Detect broken links before deployment. * Artifacts: `markdown-link-check-results` (JSON) * Annotations: Error for each broken link * Exit Code: Non-zero if broken links found +* Scope: pull request validation checks changed files only; `weekly-validation.yml` runs the full repository sweep ### ADR Consistency Validation diff --git a/scripts/linting/markdown-link-check.config.json b/scripts/linting/markdown-link-check.config.json index 0683bb6f3..83db39988 100644 --- a/scripts/linting/markdown-link-check.config.json +++ b/scripts/linting/markdown-link-check.config.json @@ -13,6 +13,7 @@ { "pattern": "^(?!#)(?!.*://)(?!.*\\.[A-Za-z0-9]+([#?].*)?$).+$" }, { "pattern": "^mailto:" }, { "pattern": "^tel:" }, + { "pattern": "^pathname://" }, { "pattern": "^https://aka\\.ms/install-hve-core.*" }, { "pattern": "^https://cleverexample\\.com/" }, { "pattern": "^https://mcp\\.figma\\.com" }, @@ -22,6 +23,7 @@ { "pattern": "^https://asmedigitalcollection\\.asme\\.org/" }, { "pattern": "^https://www\\.cyber\\.gov\\.au/" }, { "pattern": "^https://www\\.security\\.gov\\.uk/" }, + { "pattern": "^https://nvd\\.nist\\.gov" }, { "pattern": "^https://www\\.iso\\.org/" }, { "pattern": "^https://ieeexplore\\.ieee\\.org/" }, { "pattern": "^https://jobs-to-be-done\\.com/" }, diff --git a/scripts/tests/linting/Markdown-Link-Check.Tests.ps1 b/scripts/tests/linting/Markdown-Link-Check.Tests.ps1 index 40ad48c33..0536e4f67 100644 --- a/scripts/tests/linting/Markdown-Link-Check.Tests.ps1 +++ b/scripts/tests/linting/Markdown-Link-Check.Tests.ps1 @@ -16,12 +16,247 @@ BeforeAll { # Import LintingHelpers for mocking Import-Module (Join-Path $PSScriptRoot '../../linting/Modules/LintingHelpers.psm1') -Force + Import-Module (Join-Path $PSScriptRoot '../../lib/Modules/CIHelpers.psm1') -Force $script:FixtureDir = Join-Path $PSScriptRoot '../fixtures/Linting' + + function New-TestMarkdownLinkCheckReport { + [CmdletBinding()] + [OutputType([string])] + param( + [hashtable[]]$Suite + ) + + $suiteXml = foreach ($suiteDefinition in $Suite) { + $fileProperty = if (-not $suiteDefinition.ContainsKey('IncludeFile') -or $suiteDefinition.IncludeFile) { + $escapedFile = [System.Security.SecurityElement]::Escape([string]$suiteDefinition.File) + "" + } + else { + '' + } + + $links = @($suiteDefinition.Links) + $failures = @($links | Where-Object { $_.Status -eq 'dead' }).Count + $errors = @($links | Where-Object { $_.Status -eq 'error' }).Count + $testCases = foreach ($link in $links) { + $escapedUrl = [System.Security.SecurityElement]::Escape([string]$link.Url) + $statusCode = if ($null -ne $link.StatusCode) { + "" + } + else { + '' + } + $resultElement = switch ($link.Status) { + 'dead' { '' } + 'error' { '' } + 'ignored' { '' } + default { '' } + } + + @" +$statusCode$resultElement +"@ + } + + @" +$fileProperty$($testCases -join '') +"@ + } + + return "$($suiteXml -join '')" + } + + function New-TestMarkdownLinkCheckCli { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string]$Directory + ) + + $scriptPath = Join-Path $Directory 'fake-markdown-link-check.js' + $scriptContent = @' +const fs = require('fs'); +const path = require('path'); + +const args = process.argv.slice(2); +const targets = []; +let junitOutput; +let configPath; +for (let index = 0; index < args.length; index++) { + const argument = args[index]; + if (argument === '-c') { + configPath = args[++index]; + } else if (argument === '--reporters') { + index++; + } else if (argument === '--junit-output') { + junitOutput = args[++index]; + } else if (argument !== '-q') { + targets.push(argument); + } +} + +const escapeXml = (value) => value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +const decodeHtml = (value) => value + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); + +const extractTestUrls = (target) => { + const content = fs.readFileSync(target, 'utf8'); + const urls = []; + for (const pattern of [ + /\]\((https?:\/\/[^\s)]+)\)/gi, + /href="([^"]+)"/gi, + /<(https?:\/\/[^>]+)>/gi + ]) { + for (const match of content.matchAll(pattern)) { + urls.push(decodeHtml(match[1])); + } + } + return [...new Set(urls)]; +}; + +const urlMode = process.env.MARKDOWN_LINK_CHECK_TEST_URL_MODE === 'true'; +const config = configPath ? JSON.parse(fs.readFileSync(configPath, 'utf8')) : {}; +const sourceStage = (config.ignorePatterns || []).some(({ pattern }) => + pattern === '^[Hh][Tt][Tt][Pp][Ss]?://' +); +const aggregateStage = targets.some((target) => path.basename(target).startsWith('external-links-')); +const stage = sourceStage ? 'source' : (aggregateStage ? 'aggregate' : 'legacy'); +const urlsByTarget = new Map(targets.map((target) => [ + target, + urlMode || aggregateStage ? extractTestUrls(target) : [`https://example.test/${target}`] +])); +const aggregateUrls = stage === 'aggregate' ? [...new Set([...urlsByTarget.values()].flat())] : []; +const fetchedUrls = sourceStage ? [] : [...urlsByTarget.values()].flat().filter((url) => + url !== process.env.MARKDOWN_LINK_CHECK_TEST_IGNORED_URL +); +const recordPath = path.join(process.env.MARKDOWN_LINK_CHECK_TEST_LEDGER, `${process.pid}.json`); +fs.writeFileSync(recordPath, JSON.stringify({ + Targets: targets, + Stage: stage, + AggregateUrls: aggregateUrls, + FetchedUrls: fetchedUrls, + ConfigPath: configPath, + IgnorePatterns: config.ignorePatterns, + JunitOutput: junitOutput +}), 'utf8'); + +const aggregateDefect = aggregateStage ? process.env.MARKDOWN_LINK_CHECK_TEST_AGGREGATE_DEFECT : undefined; +const defectUrl = process.env.MARKDOWN_LINK_CHECK_TEST_DEFECT_URL; +const allUrls = [...urlsByTarget.values()].flat(); +const defectProcess = Boolean(aggregateDefect) && (!defectUrl || allUrls.includes(defectUrl)); + +const createResult = (url, target) => { + const ignored = sourceStage || url === process.env.MARKDOWN_LINK_CHECK_TEST_IGNORED_URL; + const isDead = urlMode + ? url === process.env.MARKDOWN_LINK_CHECK_TEST_DEAD_URL + : target === process.env.MARKDOWN_LINK_CHECK_TEST_DEAD_TARGET || + url === `https://example.test/${process.env.MARKDOWN_LINK_CHECK_TEST_DEAD_TARGET}`; + const status = ignored ? 'ignored' : (isDead ? 'dead' : 'alive'); + return { + url, + status, + statusCode: ignored ? '0' : (isDead ? '404' : '200'), + resultElement: ignored + ? '' + : (isDead ? '' : '') + }; +}; + +const renderResult = (result, applyDefect) => { + const escapedUrl = escapeXml(result.url); + const status = applyDefect && aggregateDefect === 'unsupported-status' + ? 'unsupported' + : result.status; + const properties = []; + if (!(applyDefect && aggregateDefect === 'missing-url-property')) { + properties.push(``); + if (applyDefect && aggregateDefect === 'duplicate-url-property') { + properties.push(``); + } + } + if (!(applyDefect && aggregateDefect === 'missing-status-property')) { + properties.push(``); + if (applyDefect && aggregateDefect === 'duplicate-status-property') { + properties.push(``); + } + } + properties.push(``); + if (applyDefect && aggregateDefect === 'duplicate-status-code-property') { + properties.push(``); + } + return `${properties.join('')}${result.resultElement}`; +}; + +const suites = targets.map((target) => { + const escapedTarget = escapeXml(target); + const results = urlsByTarget.get(target).map((url) => createResult(url, target)); + if (defectProcess && aggregateDefect === 'unexpected-result' && target === targets[0]) { + results.push(createResult('https://unexpected.example.test/result', target)); + } + const failures = results.filter(({ status }) => status === 'dead').length; + const skipped = results.filter(({ status }) => status === 'ignored').length; + const testCases = []; + for (const result of results) { + const applyDefect = defectProcess && (!defectUrl || result.url === defectUrl); + if (applyDefect && aggregateDefect === 'missing-result') { + continue; + } + const testCase = renderResult(result, applyDefect); + testCases.push(testCase); + if (applyDefect && aggregateDefect === 'duplicate-result') { + testCases.push(testCase); + } + } + return `${testCases.join('')}`; +}); + +if (defectProcess && aggregateDefect === 'invocation-error') { + process.exit(2); +} +if (defectProcess && aggregateDefect === 'malformed-xml') { + fs.writeFileSync(junitOutput, '', 'utf8'); + process.exit(1); +} +fs.writeFileSync(junitOutput, `${suites.join('')}`, 'utf8'); +const hasDeadResult = [...urlsByTarget.entries()].some(([target, urls]) => urlMode + ? urls.includes(process.env.MARKDOWN_LINK_CHECK_TEST_DEAD_URL) && !sourceStage + : !sourceStage && ( + target === process.env.MARKDOWN_LINK_CHECK_TEST_DEAD_TARGET || + urls.includes(`https://example.test/${process.env.MARKDOWN_LINK_CHECK_TEST_DEAD_TARGET}`) + ) +); +process.exit(hasDeadResult || (defectProcess && aggregateDefect === 'unexplained-exit') ? 1 : 0); +'@ + Set-Content -LiteralPath $scriptPath -Value $scriptContent -Encoding utf8 + + if ($IsWindows) { + $cliPath = Join-Path $Directory 'markdown-link-check.cmd' + Set-Content -LiteralPath $cliPath -Value "@node `"$scriptPath`" %*" -Encoding ascii + return $cliPath + } + + $cliPath = Join-Path $Directory 'markdown-link-check' + Set-Content -LiteralPath $cliPath -Value "#!/usr/bin/env node`n$scriptContent" -Encoding utf8 + & chmod +x $cliPath + return $cliPath + } } AfterAll { Remove-Module LintingHelpers -Force -ErrorAction SilentlyContinue + Remove-Module CIHelpers -Force -ErrorAction SilentlyContinue } #region Get-MarkdownTarget Tests @@ -75,6 +310,50 @@ Describe 'Get-MarkdownTarget' -Tag 'Unit' { } } + Context 'Changed-files-only filtering' { + BeforeEach { + Set-Content -Path (Join-Path $script:TempDir 'changed.md') -Value '# Changed' + Set-Content -Path (Join-Path $script:TempDir 'unchanged.md') -Value '# Unchanged' + + Mock git { + if ($args -contains 'rev-parse') { + $global:LASTEXITCODE = 0 + return $script:TempDir + } + elseif ($args -contains 'ls-files') { + $global:LASTEXITCODE = 0 + return @('changed.md', 'unchanged.md') + } + } + } + + It 'Restricts targets to markdown files reported as changed' { + Mock Get-ChangedFilesFromGit { @('changed.md') } + + $result = @(Get-MarkdownTarget -InputPath $script:TempDir -ChangedFilesOnly -BaseBranch 'origin/main') + + $result.Count | Should -Be 1 + [System.IO.Path]::GetFileName($result[0]) | Should -Be 'changed.md' + } + + It 'Returns no targets when no markdown files changed' { + Mock Get-ChangedFilesFromGit { @() } + + $result = @(Get-MarkdownTarget -InputPath $script:TempDir -ChangedFilesOnly) + + $result.Count | Should -Be 0 + } + + It 'Returns every discovered file when the switch is absent' { + Mock Get-ChangedFilesFromGit { @('changed.md') } + + $result = @(Get-MarkdownTarget -InputPath $script:TempDir) + + $result.Count | Should -Be 2 + Should -Invoke Get-ChangedFilesFromGit -Times 0 -Exactly + } + } + Context 'Non-git fallback mode' { BeforeEach { # Create test files @@ -262,6 +541,237 @@ Describe 'Get-RelativePrefix' -Tag 'Unit' { #endregion +#region Split-MarkdownTargetBatch Tests + +Describe 'Split-MarkdownTargetBatch' -Tag 'Unit' { + It 'Creates the throttle-derived batch count for targets and throttle when no budget applies' -ForEach @( + @{ TargetCount = 0; ThrottleLimit = 4; ExpectedBatchCount = 0 } + @{ TargetCount = 1; ThrottleLimit = 4; ExpectedBatchCount = 1 } + @{ TargetCount = 4; ThrottleLimit = 4; ExpectedBatchCount = 4 } + @{ TargetCount = 7; ThrottleLimit = 4; ExpectedBatchCount = 4 } + @{ TargetCount = 7; ThrottleLimit = 1; ExpectedBatchCount = 1 } + ) { + $targets = @(0..($TargetCount - 1) | ForEach-Object { "file-$_.md" }) + if ($TargetCount -eq 0) { + $targets = @() + } + + $batches = @(Split-MarkdownTargetBatch -Target $targets -ThrottleLimit $ThrottleLimit) + + $batches.Count | Should -Be $ExpectedBatchCount + @($batches | Where-Object { @($_.Files).Count -eq 0 }).Count | Should -Be 0 + } + + It 'Sorts targets and distributes them into balanced contiguous batches' { + $batches = @(Split-MarkdownTargetBatch -Target @('z.md', 'a.md', 'm.md', 'b.md', 'x.md') -ThrottleLimit 2) + + @($batches[0].Files) | Should -Be @('a.md', 'b.md', 'm.md') + @($batches[1].Files) | Should -Be @('x.md', 'z.md') + $sizes = @($batches | ForEach-Object { @($_.Files).Count }) + (($sizes | Measure-Object -Maximum).Maximum - ($sizes | Measure-Object -Minimum).Minimum) | Should -BeLessOrEqual 1 + } + + It 'Keeps every batch within the budget when combined argument length crosses it' { + $targets = @(0..79 | ForEach-Object { + 'docs/deeply/nested/section/file-{0:D3}-{1}.md' -f $_, ('x' * 20) + }) + $budget = 1024 + $reserved = 512 + $available = $budget - $reserved + + $batches = @(Split-MarkdownTargetBatch ` + -Target $targets ` + -ThrottleLimit 4 ` + -ArgumentLengthBudget $budget ` + -ReservedLength $reserved) + + $batches.Count | Should -BeGreaterThan 4 + foreach ($batch in $batches) { + $files = @($batch.Files) + $length = (($files | Measure-Object -Property Length -Sum).Sum) + (3 * $files.Count) + $length | Should -BeLessOrEqual $available + } + @($batches | ForEach-Object { @($_.Files) }) | Should -Be @($targets | Sort-Object) + @($batches.Index) | Should -Be @(0..($batches.Count - 1)) + } + + It 'Reproduces the unbudgeted batches when no batch would exceed the budget' { + $targets = @(0..9 | ForEach-Object { "file-$_.md" }) + + $unbudgeted = @(Split-MarkdownTargetBatch -Target $targets -ThrottleLimit 4) + $budgeted = @(Split-MarkdownTargetBatch ` + -Target $targets ` + -ThrottleLimit 4 ` + -ArgumentLengthBudget 8191 ` + -ReservedLength 200) + + @($budgeted | ForEach-Object { $_.Files -join '|' }) | + Should -Be @($unbudgeted | ForEach-Object { $_.Files -join '|' }) + } + + It 'Throws when a single target alone exceeds the available length' { + { + Split-MarkdownTargetBatch ` + -Target @((('a' * 200) + '.md')) ` + -ThrottleLimit 1 ` + -ArgumentLengthBudget 100 ` + -ReservedLength 10 + } | Should -Throw -ExpectedMessage '*alone exceeds the available command-line length*' + } + + It 'Leaves URL batching bounded only by the throttle limit' { + $urls = @(0..19 | ForEach-Object { 'https://example.test/{0}/{1}' -f $_, ('y' * 2000) }) + + $batches = @(Split-MarkdownTargetBatch -Target $urls -ThrottleLimit 4) + + $batches.Count | Should -Be 4 + (($batches | ForEach-Object { ($_.Files | Measure-Object -Property Length -Sum).Sum } | + Measure-Object -Maximum).Maximum) | Should -BeGreaterThan 8191 + } +} + +#endregion + +#region Get-MarkdownBatchLengthBudget Tests + +Describe 'Get-MarkdownBatchLengthBudget' -Tag 'Unit' { + It 'Reserves the CLI path, base arguments, and reporter arguments' { + $cli = '/repo/node_modules/.bin/markdown-link-check' + $baseArgument = @('-c', '/tmp/task-workspace/source-config.json', '-q') + $reportPath = '/tmp/task-workspace/source-00.xml' + + $result = Get-MarkdownBatchLengthBudget -Cli $cli -BaseArgument $baseArgument -ReportPath $reportPath + + $fixed = @($cli) + $baseArgument + @('--reporters', 'default,junit', '--junit-output', $reportPath) + $expected = (@($fixed | ForEach-Object { $_.Length + 3 }) | Measure-Object -Sum).Sum + if ($IsWindows) { + $expected += 'cmd.exe /c ""'.Length + } + + $result.Reserved | Should -Be $expected + $result.Reserved | Should -BeLessThan $result.Budget + } + + It 'Uses the command-interpreter ceiling on Windows rather than the CreateProcess ceiling' { + $result = Get-MarkdownBatchLengthBudget -Cli 'cli' -BaseArgument @('-c', 'config.json') -ReportPath 'report.xml' + + if ($IsWindows) { + $result.Budget | Should -Be 8191 + } + else { + $result.Budget | Should -BeGreaterThan 8191 + } + } +} + +#endregion + +#region Test-MarkdownExternalScope Tests + +Describe 'Test-MarkdownExternalScope' -Tag 'Unit' { + It 'Treats an absent scope as every file in scope and an empty scope as no file in scope' { + $emptyScope = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + Test-MarkdownExternalScope -RelativePath 'docs/readme.md' -Scope $null | Should -BeTrue + Test-MarkdownExternalScope -RelativePath 'docs/readme.md' -Scope $emptyScope | Should -BeFalse + } + + It 'Matches a platform-separator source path against a forward-slash scope key' { + $scope = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + [void]$scope.Add('docs/guide/readme.md') + + Test-MarkdownExternalScope -RelativePath 'docs\guide\readme.md' -Scope $scope | Should -BeTrue + Test-MarkdownExternalScope -RelativePath 'DOCS\GUIDE\README.MD' -Scope $scope | Should -BeTrue + Test-MarkdownExternalScope -RelativePath 'docs/guide/other.md' -Scope $scope | Should -BeFalse + } +} + +#endregion + +#region ConvertFrom-MarkdownLinkCheckReport Tests + +Describe 'ConvertFrom-MarkdownLinkCheckReport' -Tag 'Unit' { + It 'Maps trustworthy suites by normalized full file path and explains a nonzero exit selectively' { + $report = New-TestMarkdownLinkCheckReport -Suite @( + @{ File = 'docs\readme.md'; Links = @(@{ Url = 'https://alive.test'; Status = 'alive'; StatusCode = 200 }) } + @{ File = 'other/readme.md'; Links = @(@{ Url = 'https://dead.test'; Status = 'dead'; StatusCode = 404 }) } + @{ File = 'third.md'; Links = @(@{ Url = 'https://ignored.test'; Status = 'ignored'; StatusCode = 0 }) } + @{ File = 'fourth.md'; Links = @(@{ Url = 'https://error.test'; Status = 'error'; StatusCode = 500 }) } + ) + + $results = @(ConvertFrom-MarkdownLinkCheckReport ` + -ExpectedFile @('other/readme.md', 'docs/readme.md', 'third.md', 'fourth.md') ` + -ReportContent $report -ExitCode 1) + + @($results.File) | Should -Be @('docs/readme.md', 'fourth.md', 'other/readme.md', 'third.md') + ($results | Where-Object File -eq 'docs/readme.md').Failed | Should -BeFalse + ($results | Where-Object File -eq 'other/readme.md').Failed | Should -BeTrue + ($results | Where-Object File -eq 'fourth.md').Failed | Should -BeTrue + ($results | Where-Object File -eq 'third.md').Failed | Should -BeFalse + @($results | Where-Object ParseFailed).Count | Should -Be 0 + } + + It 'Fails every expected file for an unexplained nonzero exit' { + $report = New-TestMarkdownLinkCheckReport -Suite @( + @{ File = 'a.md'; Links = @(@{ Url = 'https://a.test'; Status = 'alive'; StatusCode = 200 }) } + @{ File = 'b.md'; Links = @(@{ Url = 'https://b.test'; Status = 'alive'; StatusCode = 200 }) } + ) + + $results = @(ConvertFrom-MarkdownLinkCheckReport -ExpectedFile @('a.md', 'b.md') -ReportContent $report -ExitCode 1) + + @($results | Where-Object Failed).Count | Should -Be 2 + @($results | Where-Object ParseFailed).Count | Should -Be 0 + } + + It 'Fails closed for every untrustworthy report shape' { + $expected = @('a.md', 'b.md') + $cases = @( + @{ Name = 'absent'; Report = $null } + @{ Name = 'malformed'; Report = '' } + @{ Name = 'missing file property'; Report = (New-TestMarkdownLinkCheckReport -Suite @( + @{ IncludeFile = $false; Links = @() } + @{ File = 'b.md'; Links = @() } + )) } + @{ Name = 'empty file property'; Report = (New-TestMarkdownLinkCheckReport -Suite @( + @{ File = ''; Links = @() } + @{ File = 'b.md'; Links = @() } + )) } + @{ Name = 'duplicate file'; Report = (New-TestMarkdownLinkCheckReport -Suite @( + @{ File = 'a.md'; Links = @() } + @{ File = 'a.md'; Links = @() } + )) } + @{ Name = 'unexpected file'; Report = (New-TestMarkdownLinkCheckReport -Suite @( + @{ File = 'a.md'; Links = @() } + @{ File = 'unexpected.md'; Links = @() } + )) } + @{ Name = 'missing expected file'; Report = (New-TestMarkdownLinkCheckReport -Suite @( + @{ File = 'a.md'; Links = @() } + )) } + ) + + foreach ($case in $cases) { + $results = @(ConvertFrom-MarkdownLinkCheckReport -ExpectedFile $expected -ReportContent $case.Report -ExitCode 0) + + @($results | Where-Object Failed).Count | Should -Be 2 -Because $case.Name + @($results | Where-Object ParseFailed).Count | Should -Be 2 -Because $case.Name + } + } + + It 'Retains known dead-link details when incomplete attribution fails the batch' { + $report = New-TestMarkdownLinkCheckReport -Suite @( + @{ File = 'a.md'; Links = @(@{ Url = 'https://dead.test'; Status = 'dead'; StatusCode = 404 }) } + ) + + $results = @(ConvertFrom-MarkdownLinkCheckReport -ExpectedFile @('a.md', 'b.md') -ReportContent $report -ExitCode 1) + + @($results | Where-Object Failed).Count | Should -Be 2 + @($results | Where-Object ParseFailed).Count | Should -Be 2 + ($results | Where-Object File -eq 'a.md').Links[0].Url | Should -Be 'https://dead.test' + } +} + +#endregion + #region Script Integration Tests Describe 'Markdown-Link-Check Integration' -Tag 'Integration' { @@ -363,14 +873,10 @@ Describe 'Invoke-MarkdownLinkCheck' -Tag 'Unit' { Context 'Quiet mode base arguments' { It 'Passes -q flag when Quiet switch is set' { Mock Get-MarkdownTarget { return @('file.md') } - Mock Resolve-Path { return [PSCustomObject]@{ Path = $script:RepoRoot } } Mock Test-Path { return $true } -ParameterFilter { $LiteralPath -and $LiteralPath -like '*markdown-link-check*' } - Mock Push-Location { } - Mock Pop-Location { } Mock Resolve-Path { return [PSCustomObject]@{ Path = "$TestDrive/file.md" } } -ParameterFilter { $LiteralPath -eq 'file.md' } - Mock New-Item { } -ParameterFilter { $ItemType -eq 'Directory' } - Mock Set-Content { } Mock Write-Host { } + Mock Invoke-MarkdownLinkCheckBatch { return @() } try { Invoke-MarkdownLinkCheck -Path @('file.md') -ConfigPath $script:FixtureConfig -Quiet @@ -380,7 +886,9 @@ Describe 'Invoke-MarkdownLinkCheck' -Tag 'Unit' { } Should -Invoke Get-MarkdownTarget -Times 1 - Should -Invoke Push-Location -Times 1 + Should -Invoke Invoke-MarkdownLinkCheckBatch -Times 1 -ParameterFilter { + $BaseArgument -contains '-q' + } } } @@ -392,10 +900,403 @@ Describe 'Invoke-MarkdownLinkCheck' -Tag 'Unit' { } } - Context 'Malformed report handling' { - It 'Marks XML parsing failures as file failures regardless of the CLI exit code' { - $src = Get-Content (Join-Path $PSScriptRoot '../../linting/Markdown-Link-Check.ps1') -Raw - $src | Should -Match '(?s)catch\s*\{\s*Write-Warning "Failed to parse XML output.*?\$failedFiles \+= \$relative' + Context 'Batched CLI boundary and aggregation' { + BeforeEach { + $script:LedgerDir = Join-Path $TestDrive ([System.Guid]::NewGuid().ToString()) + New-Item -ItemType Directory -Path $script:LedgerDir -Force | Out-Null + $env:MARKDOWN_LINK_CHECK_TEST_LEDGER = $script:LedgerDir + $script:MarkdownLinkCheckCliOverride = New-TestMarkdownLinkCheckCli -Directory $TestDrive + + $targetDir = Join-Path $TestDrive 'batch-targets' + New-Item -ItemType Directory -Path $targetDir -Force | Out-Null + $script:BatchTargets = @('z.md', 'a.md', 'm.md', 'b.md', 'x.md') | ForEach-Object { + $targetPath = Join-Path $targetDir $_ + Set-Content -LiteralPath $targetPath -Value '# Test' -Encoding utf8 + $targetPath + } + $script:ExpectedRelativeTargets = @($script:BatchTargets | ForEach-Object { + [System.IO.Path]::GetRelativePath($script:RepoRoot, $_) + } | Sort-Object) + + $script:ResultsPath = Join-Path $script:RepoRoot 'logs/markdown-link-check-results.json' + $script:ChangedTargets = @() + $script:OriginalResults = if (Test-Path -LiteralPath $script:ResultsPath) { + Get-Content -LiteralPath $script:ResultsPath -Raw + } + else { + $null + } + + Mock Get-MarkdownTarget { return $script:BatchTargets } + # The orchestrator resolves scope twice: once for the repository-wide + # source pass and once, with the switch, for the external-link scope. + Mock Get-MarkdownTarget { return $script:ChangedTargets } -ParameterFilter { $ChangedFilesOnly } + Mock Write-CIStepSummary { } + Mock Write-CIAnnotation { } + Mock Set-CIEnv { } + Mock Write-Host { } + } + + AfterEach { + Remove-Item Env:MARKDOWN_LINK_CHECK_TEST_LEDGER -ErrorAction SilentlyContinue + Remove-Item Env:MARKDOWN_LINK_CHECK_TEST_DEAD_TARGET -ErrorAction SilentlyContinue + Remove-Item Env:MARKDOWN_LINK_CHECK_TEST_URL_MODE -ErrorAction SilentlyContinue + Remove-Item Env:MARKDOWN_LINK_CHECK_TEST_DEAD_URL -ErrorAction SilentlyContinue + Remove-Item Env:MARKDOWN_LINK_CHECK_TEST_IGNORED_URL -ErrorAction SilentlyContinue + Remove-Item Env:MARKDOWN_LINK_CHECK_TEST_AGGREGATE_DEFECT -ErrorAction SilentlyContinue + Remove-Item Env:MARKDOWN_LINK_CHECK_TEST_DEFECT_URL -ErrorAction SilentlyContinue + Remove-Variable MarkdownLinkCheckCliOverride -Scope Script -ErrorAction SilentlyContinue + if ($null -ne $script:OriginalResults) { + Set-Content -LiteralPath $script:ResultsPath -Value $script:OriginalResults -NoNewline -Encoding utf8 + } + else { + Remove-Item -LiteralPath $script:ResultsPath -Force -ErrorAction SilentlyContinue + } + } + + It 'Invokes bounded source and aggregate processes without flattening or dropping targets' { + Invoke-MarkdownLinkCheck -Path @('unused') -ConfigPath $script:FixtureConfig -ThrottleLimit 2 -Quiet + + $records = @(Get-ChildItem -LiteralPath $script:LedgerDir -Filter '*.json' | ForEach-Object { + Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + }) + $sourceRecords = @($records | Where-Object Stage -eq 'source') + $aggregateRecords = @($records | Where-Object Stage -eq 'aggregate') + $recordedTargets = @($sourceRecords | ForEach-Object { @($_.Targets) }) + + $sourceRecords.Count | Should -Be 2 + $aggregateRecords.Count | Should -Be 2 + @($sourceRecords | Where-Object { @($_.Targets).Count -eq 0 }).Count | Should -Be 0 + @($sourceRecords | Where-Object { @($_.Targets).Count -gt 1 }).Count | Should -Be 2 + @($aggregateRecords | Where-Object { @($_.Targets).Count -ne 1 }).Count | Should -Be 0 + @($recordedTargets | Sort-Object) | Should -Be $script:ExpectedRelativeTargets + foreach ($target in $script:ExpectedRelativeTargets) { + @($recordedTargets | Where-Object { $_ -eq $target }).Count | Should -Be 1 + } + } + + It 'Checks one shared exact external URL once and replays a dead result to both source files' { + $sharedUrl = 'https://dead.example.test/shared' + $env:MARKDOWN_LINK_CHECK_TEST_URL_MODE = 'true' + $env:MARKDOWN_LINK_CHECK_TEST_DEAD_URL = $sharedUrl + $script:BatchTargets = @('first.md', 'second.md') | ForEach-Object { + $targetPath = Join-Path (Split-Path $script:BatchTargets[0] -Parent) $_ + Set-Content -LiteralPath $targetPath -Value "[Shared]($sharedUrl)" -Encoding utf8 + $targetPath + } + + $captured = @(& { + try { + Invoke-MarkdownLinkCheck -Path @('unused') -ConfigPath $script:FixtureConfig -ThrottleLimit 2 -Quiet + } + catch { + Write-Output "THREW: $($_.Exception.Message)" + } + }) + + $records = @(Get-ChildItem -LiteralPath $script:LedgerDir -Filter '*.json' | ForEach-Object { + Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + }) + $aggregateUrls = @($records | ForEach-Object { @($_.AggregateUrls) }) + $fetchedUrls = @($records | ForEach-Object { @($_.FetchedUrls) }) + $result = Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json + + @($aggregateUrls | Where-Object { $_ -eq $sharedUrl }).Count | Should -Be 1 + @($fetchedUrls | Where-Object { $_ -eq $sharedUrl }).Count | Should -Be 1 + $result.summary.total_files | Should -Be 2 + $result.summary.files_with_broken_links | Should -Be 2 + $result.summary.total_links_checked | Should -Be 2 + $result.summary.total_broken_links | Should -Be 2 + @($result.broken_links.Link | Where-Object { $_ -eq $sharedUrl }).Count | Should -Be 2 + @($captured | Where-Object { $_ -like 'THREW:*' }).Count | Should -Be 1 + Should -Invoke Write-CIAnnotation -Times 2 -Exactly -ParameterFilter { + $Level -eq 'Error' -and $Message -like "Broken link: $sharedUrl*" + } + } + + It 'Keeps exact variants disjoint and preserves original ignored-link behavior' { + $sharedUrl = 'https://example.test/shared' + $queryUrl = 'https://example.test/shared?view=one' + $fragmentUrl = 'https://example.test/shared#section' + $ignoredUrl = 'https://ignored.example.test/link' + $env:MARKDOWN_LINK_CHECK_TEST_URL_MODE = 'true' + $env:MARKDOWN_LINK_CHECK_TEST_IGNORED_URL = $ignoredUrl + $targetDir = Split-Path $script:BatchTargets[0] -Parent + $script:BatchTargets = @( + @{ Name = 'first.md'; Content = "[Shared]($sharedUrl)`n[Query]($queryUrl)" } + @{ Name = 'second.md'; Content = "[Shared]($sharedUrl)`n[Fragment]($fragmentUrl)" } + @{ Name = 'third.md'; Content = "[Ignored]($ignoredUrl)" } + ) | ForEach-Object { + $targetPath = Join-Path $targetDir $_.Name + Set-Content -LiteralPath $targetPath -Value $_.Content -Encoding utf8 + $targetPath + } + + Invoke-MarkdownLinkCheck -Path @('unused') -ConfigPath $script:FixtureConfig -ThrottleLimit 2 -Quiet + + $records = @(Get-ChildItem -LiteralPath $script:LedgerDir -Filter '*.json' | ForEach-Object { + Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + }) + $aggregateRecords = @($records | Where-Object Stage -eq 'aggregate') + $aggregateUrls = @($aggregateRecords | ForEach-Object { @($_.AggregateUrls) }) + $fetchedUrls = @($records | ForEach-Object { @($_.FetchedUrls) }) + $result = Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json + + $aggregateRecords.Count | Should -Be 2 + @($aggregateUrls | Sort-Object) | Should -Be @($fragmentUrl, $ignoredUrl, $queryUrl, $sharedUrl | Sort-Object) + foreach ($url in @($sharedUrl, $queryUrl, $fragmentUrl, $ignoredUrl)) { + @($aggregateUrls | Where-Object { $_ -eq $url }).Count | Should -Be 1 + } + @($fetchedUrls | Where-Object { $_ -eq $sharedUrl }).Count | Should -Be 1 + @($fetchedUrls | Where-Object { $_ -eq $queryUrl }).Count | Should -Be 1 + @($fetchedUrls | Where-Object { $_ -eq $fragmentUrl }).Count | Should -Be 1 + @($fetchedUrls | Where-Object { $_ -eq $ignoredUrl }).Count | Should -Be 0 + $result.summary.total_files | Should -Be 3 + $result.summary.total_links_checked | Should -Be 5 + $result.summary.total_broken_links | Should -Be 0 + } + + It 'Supports a checker config that omits ignorePatterns' { + $configWithoutIgnorePatterns = Join-Path $TestDrive 'config-without-ignore-patterns.json' + @{ + projectBaseUrl = '.' + timeout = '20s' + retryOn429 = $true + replacementPatterns = @() + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $configWithoutIgnorePatterns -Encoding utf8 + $env:MARKDOWN_LINK_CHECK_TEST_URL_MODE = 'true' + $targetDir = Split-Path $script:BatchTargets[0] -Parent + $targetPath = Join-Path $targetDir 'without-ignore-patterns.md' + Set-Content -LiteralPath $targetPath -Value '[Link](https://example.test/config)' -Encoding utf8 + $script:BatchTargets = @($targetPath) + + { Invoke-MarkdownLinkCheck ` + -Path @('unused') ` + -ConfigPath $configWithoutIgnorePatterns ` + -ThrottleLimit 1 ` + -Quiet } | Should -Not -Throw + + $records = @(Get-ChildItem -LiteralPath $script:LedgerDir -Filter '*.json' | ForEach-Object { + Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + }) + $sourceRecord = @($records | Where-Object Stage -eq 'source') + $sourceRecord.Count | Should -Be 1 + @($sourceRecord[0].IgnorePatterns).Count | Should -Be 1 + $sourceRecord[0].IgnorePatterns[0].pattern | Should -Be '^[Hh][Tt][Tt][Pp][Ss]?://' + } + + It 'Fails only the source file mapped to an aggregate with ' -ForEach @( + @{ Defect = 'missing-url-property' } + @{ Defect = 'duplicate-url-property' } + @{ Defect = 'missing-status-property' } + @{ Defect = 'duplicate-status-property' } + @{ Defect = 'duplicate-status-code-property' } + @{ Defect = 'unsupported-status' } + @{ Defect = 'missing-result' } + @{ Defect = 'duplicate-result' } + @{ Defect = 'unexpected-result' } + @{ Defect = 'unexplained-exit' } + ) { + $defectUrl = 'https://example.test/defect' + $healthyUrl = 'https://example.test/healthy' + $env:MARKDOWN_LINK_CHECK_TEST_URL_MODE = 'true' + $env:MARKDOWN_LINK_CHECK_TEST_AGGREGATE_DEFECT = $Defect + $env:MARKDOWN_LINK_CHECK_TEST_DEFECT_URL = $defectUrl + $targetDir = Split-Path $script:BatchTargets[0] -Parent + $script:BatchTargets = @( + @{ Name = 'defect.md'; Url = $defectUrl } + @{ Name = 'healthy.md'; Url = $healthyUrl } + ) | ForEach-Object { + $targetPath = Join-Path $targetDir $_.Name + Set-Content -LiteralPath $targetPath -Value "[Link]($($_.Url))" -Encoding utf8 + $targetPath + } + + $captured = @(& { + try { + Invoke-MarkdownLinkCheck -Path @('unused') -ConfigPath $script:FixtureConfig -ThrottleLimit 2 -Quiet + } + catch { + Write-Output "THREW: $($_.Exception.Message)" + } + }) + + $result = Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json + $result.summary.files_with_broken_links | Should -Be 1 + $result.summary.total_broken_links | Should -Be 0 + @($captured | Where-Object { $_ -like 'THREW:*' }).Count | Should -Be 1 + Should -Invoke Write-CIAnnotation -Times 0 -Exactly + } + + It 'Removes one common task workspace after ' -ForEach @( + @{ Outcome = 'success'; Defect = $null; Dead = $false } + @{ Outcome = 'dead link'; Defect = $null; Dead = $true } + @{ Outcome = 'malformed report'; Defect = 'malformed-xml'; Dead = $false } + @{ Outcome = 'invocation error'; Defect = 'invocation-error'; Dead = $false } + ) { + $url = 'https://example.test/cleanup' + $env:MARKDOWN_LINK_CHECK_TEST_URL_MODE = 'true' + if ($Dead) { + $env:MARKDOWN_LINK_CHECK_TEST_DEAD_URL = $url + } + if ($Defect) { + $env:MARKDOWN_LINK_CHECK_TEST_AGGREGATE_DEFECT = $Defect + $env:MARKDOWN_LINK_CHECK_TEST_DEFECT_URL = $url + } + $targetDir = Split-Path $script:BatchTargets[0] -Parent + $targetPath = Join-Path $targetDir 'cleanup.md' + Set-Content -LiteralPath $targetPath -Value "[Cleanup]($url)" -Encoding utf8 + $script:BatchTargets = @($targetPath) + + try { + Invoke-MarkdownLinkCheck -Path @('unused') -ConfigPath $script:FixtureConfig -ThrottleLimit 1 -Quiet + } + catch { + Write-Verbose "Expected controlled $Outcome result: $_" + } + + $records = @(Get-ChildItem -LiteralPath $script:LedgerDir -Filter '*.json' | ForEach-Object { + Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + }) + $derivedConfig = @($records.ConfigPath | Where-Object { + [System.IO.Path]::GetFileName($_) -eq 'source-config.json' + } | Select-Object -Unique) + $derivedConfig.Count | Should -Be 1 + $taskRoot = Split-Path $derivedConfig[0] -Parent + $taskPaths = @( + $derivedConfig + $records.JunitOutput + $records | Where-Object Stage -eq 'aggregate' | ForEach-Object { @($_.Targets) } + ) + @($taskPaths | Where-Object { -not $_.StartsWith($taskRoot, [System.StringComparison]::Ordinal) }).Count | Should -Be 0 + Test-Path -LiteralPath $taskRoot | Should -BeFalse + } + + It 'Preserves mixed batch JSON, annotation, summary, and sorted output semantics' { + $env:MARKDOWN_LINK_CHECK_TEST_DEAD_TARGET = $script:ExpectedRelativeTargets[2] + + $captured = @(& { + try { + Invoke-MarkdownLinkCheck -Path @('unused') -ConfigPath $script:FixtureConfig -ThrottleLimit 2 -Quiet + } + catch { + Write-Output "THREW: $($_.Exception.Message)" + } + }) + + $result = Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json + @($result.PSObject.Properties.Name | Sort-Object) | Should -Be @('broken_links', 'script', 'summary', 'Timestamp') + $result.summary.total_files | Should -Be 5 + $result.summary.files_with_broken_links | Should -Be 1 + $result.summary.total_links_checked | Should -Be 5 + $result.summary.skipped_links | Should -Be 0 + $result.summary.total_broken_links | Should -Be 1 + $result.broken_links[0].File | Should -Be $env:MARKDOWN_LINK_CHECK_TEST_DEAD_TARGET + @($captured | Where-Object { $_ -like 'Checking *' } | ForEach-Object { $_ -replace '^Checking ', '' }) | + Should -Be $script:ExpectedRelativeTargets + @($captured | Where-Object { $_ -like 'THREW:*' }).Count | Should -Be 1 + Should -Invoke Write-CIAnnotation -Times 1 -Exactly -ParameterFilter { + $Level -eq 'Error' -and $File -eq $env:MARKDOWN_LINK_CHECK_TEST_DEAD_TARGET + } + Should -Invoke Write-CIStepSummary -Times 1 -Exactly -ParameterFilter { + $Content -match 'Files with broken links:\*\* 1 / 5' -and + $Content -match 'Total broken links:\*\* 1' + } + } + + It 'Validates internal links repository-wide and skips external checks when no markdown changed' { + $env:MARKDOWN_LINK_CHECK_TEST_URL_MODE = 'true' + $targetDir = Split-Path $script:BatchTargets[0] -Parent + $targetPath = Join-Path $targetDir 'unchanged.md' + Set-Content -LiteralPath $targetPath -Value '[External](https://example.test/unchanged)' -Encoding utf8 + $script:BatchTargets = @($targetPath) + $script:ChangedTargets = @() + + $output = @(Invoke-MarkdownLinkCheck -Path @('unused') -ConfigPath $script:FixtureConfig -ChangedFilesOnly -ThrottleLimit 2 -Quiet) + + $records = @(Get-ChildItem -LiteralPath $script:LedgerDir -Filter '*.json' | ForEach-Object { + Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + }) + $result = Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json + + @($output | Where-Object { + $_ -eq 'No changed markdown files; external link validation was skipped.' + }).Count | Should -Be 1 + @($records | Where-Object Stage -eq 'source').Count | Should -BeGreaterThan 0 + @($records | Where-Object Stage -eq 'aggregate').Count | Should -Be 0 + $result.summary.total_files | Should -Be 1 + $result.summary.files_with_broken_links | Should -Be 0 + $result.summary.total_links_checked | Should -Be 0 + $result.summary.skipped_links | Should -Be 1 + $result.summary.total_broken_links | Should -Be 0 + } + + It 'Fetches a shared URL for the changed file and reports the unchanged file as skipped' { + $sharedUrl = 'https://example.test/shared-scope' + $env:MARKDOWN_LINK_CHECK_TEST_URL_MODE = 'true' + $targetDir = Split-Path $script:BatchTargets[0] -Parent + $script:BatchTargets = @('changed.md', 'unchanged.md') | ForEach-Object { + $targetPath = Join-Path $targetDir $_ + Set-Content -LiteralPath $targetPath -Value "[Shared]($sharedUrl)" -Encoding utf8 + $targetPath + } + $script:ChangedTargets = @($script:BatchTargets[0]) + + Invoke-MarkdownLinkCheck -Path @('unused') -ConfigPath $script:FixtureConfig -ChangedFilesOnly -ThrottleLimit 2 -Quiet + + $records = @(Get-ChildItem -LiteralPath $script:LedgerDir -Filter '*.json' | ForEach-Object { + Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + }) + $fetchedUrls = @($records | ForEach-Object { @($_.FetchedUrls) }) + $result = Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json + + @($fetchedUrls | Where-Object { $_ -eq $sharedUrl }).Count | Should -Be 1 + $result.summary.total_files | Should -Be 2 + $result.summary.total_links_checked | Should -Be 1 + $result.summary.skipped_links | Should -Be 1 + $result.summary.files_with_broken_links | Should -Be 0 + $result.summary.total_broken_links | Should -Be 0 + } + + It 'Fails an in-scope file closed for a missing aggregate result while an out-of-scope file is unaffected' { + $env:MARKDOWN_LINK_CHECK_TEST_URL_MODE = 'true' + $env:MARKDOWN_LINK_CHECK_TEST_AGGREGATE_DEFECT = 'malformed-xml' + $targetDir = Split-Path $script:BatchTargets[0] -Parent + $inScope = Join-Path $targetDir 'in-scope.md' + Set-Content -LiteralPath $inScope -Value '[In](https://example.test/in-scope)' -Encoding utf8 + $outOfScope = Join-Path $targetDir 'out-of-scope.md' + Set-Content -LiteralPath $outOfScope -Value '[Out](https://example.test/out-of-scope)' -Encoding utf8 + $script:BatchTargets = @($inScope, $outOfScope) + $script:ChangedTargets = @($inScope) + + $captured = @(& { + try { + Invoke-MarkdownLinkCheck -Path @('unused') -ConfigPath $script:FixtureConfig -ChangedFilesOnly -ThrottleLimit 2 -Quiet + } + catch { + Write-Output "THREW: $($_.Exception.Message)" + } + }) + + $records = @(Get-ChildItem -LiteralPath $script:LedgerDir -Filter '*.json' | ForEach-Object { + Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + }) + $aggregateUrls = @($records | ForEach-Object { @($_.AggregateUrls) }) + $result = Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json + + @($aggregateUrls | Where-Object { $_ -eq 'https://example.test/out-of-scope' }).Count | Should -Be 0 + $result.summary.total_files | Should -Be 2 + $result.summary.files_with_broken_links | Should -Be 1 + @($captured | Where-Object { $_ -like 'THREW:*' }).Count | Should -Be 1 + } + + It 'Throws when the repository-wide target set is empty even with the changed-files switch' { + $script:BatchTargets = @() + $script:ChangedTargets = @() + + { Invoke-MarkdownLinkCheck -Path @('unused') -ConfigPath $script:FixtureConfig -ChangedFilesOnly -ThrottleLimit 2 -Quiet } | + Should -Throw -ExpectedMessage 'No markdown files were found to validate.' + @(Get-ChildItem -LiteralPath $script:LedgerDir -Filter '*.json').Count | Should -Be 0 } } }