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
}
}
}