From b9c2cc635be69c544114e0f6125dc0a84c142a25 Mon Sep 17 00:00:00 2001 From: Victor Colin Amador Date: Mon, 14 Sep 2026 16:27:40 -0700 Subject: [PATCH 1/2] Exclude reviewed root documents from the Java PR pipeline --- .github/workflows/check-spelling.yml | 6 + eng/README.md | 31 +++ eng/pipelines/pullrequest.yml | 18 +- .../Test-RootDocumentationExclusions.ps1 | 116 ++++++++++ .../tests/PullRequest-Trigger.tests.ps1 | 154 ++++++++++++- .../RootDocumentationExclusions.tests.ps1 | 214 ++++++++++++++++++ 6 files changed, 532 insertions(+), 7 deletions(-) create mode 100644 eng/scripts/Test-RootDocumentationExclusions.ps1 create mode 100644 eng/scripts/tests/RootDocumentationExclusions.tests.ps1 diff --git a/.github/workflows/check-spelling.yml b/.github/workflows/check-spelling.yml index ff758f223aa2e..2e37b26afca91 100644 --- a/.github/workflows/check-spelling.yml +++ b/.github/workflows/check-spelling.yml @@ -37,3 +37,9 @@ jobs: -ExitWithError -SourceCommittish HEAD -TargetCommittish HEAD^ + + - name: Check root documentation exclusions + # Inventory all tracked paths, even when spelling fails or checks no files. + if: ${{ !cancelled() }} + shell: pwsh + run: ./eng/scripts/Test-RootDocumentationExclusions.ps1 diff --git a/eng/README.md b/eng/README.md index 31438503e2b6e..8741edd16a19c 100644 --- a/eng/README.md +++ b/eng/README.md @@ -10,6 +10,37 @@ All the tools/utilities used in Microsoft Azure Java SDK's build config are defi - `lintingconfigs` - CheckStyle and SpotBugs rule configurations. +## PR Documentation Validation + +The unified Java PR pipeline excludes `docs/**`, shared `.github/skills/azsdk-common-*/**` content, and exactly +these repository-root documents: `AGENTS.md`, `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, `LICENSE.txt`, `NOTICE.txt`, +`README.md`, `SECURITY.md`, and `SUPPORT.md`. The `docs/` and root-document entries are also listed in +`ExcludePaths` in [pullrequest.yml](pipelines/pullrequest.yml), so they do not select Java packages in mixed PRs. +SDK-package documents, CHANGELOGs, source/resources, and unknown paths gain no trigger exclusions. +Build/Analyze orchestration and the existing test-matrix classifier are unchanged. + +The required **Check Spelling** job still checks all supported PR branches without path filters, using the existing +CSpell configuration and ignore rules. In the same job, [Test-RootDocumentationExclusions.ps1](scripts/Test-RootDocumentationExclusions.ps1) +checks the entire tracked-path inventory, even if spelling fails or has no files to check. A native regex prefilter +limits detailed comparisons to root candidates, including unusual root characters needed for culture-aware matching. +This temporary guard rejects longer prefixes (such as `README.md.template` or `README.md/src/Example.java`) and +case-only aliases because package selection still uses prefix matching. Nested names such as `sdk/example/README.md` +do not collide with root exclusions. Git inventory failures also fail the job. Rename a colliding path or remove +its matching root-document exclusion from both lists before adding it. Shared matcher hardening remains an upstream +`azure-sdk-tools` change; do not patch `eng/common` locally. **Verify Links** remains a separate, unchanged workflow. + +Run the guard and its regression tests with PowerShell 7, Git, and the CI-declared Pester 5.7.1 (no YAML module required): + +```powershell +./eng/scripts/Test-RootDocumentationExclusions.ps1 +Import-Module Pester -RequiredVersion 5.7.1 +Invoke-Pester -Path @( + 'eng/scripts/tests/PullRequest-Trigger.tests.ps1', + 'eng/scripts/tests/RootDocumentationExclusions.tests.ps1', + 'eng/scripts/tests/Classify-PRChanges.tests.ps1' +) -Tag UnitTest -Output Detailed +``` + ## Sparse Checkouts Java-owned pipeline jobs use the native Azure Pipelines diff --git a/eng/pipelines/pullrequest.yml b/eng/pipelines/pullrequest.yml index 443cda76e4788..6587be7a591a9 100644 --- a/eng/pipelines/pullrequest.yml +++ b/eng/pipelines/pullrequest.yml @@ -11,6 +11,14 @@ pr: exclude: - .github/skills/azsdk-common-*/** - docs/** + - AGENTS.md + - CODE_OF_CONDUCT.md + - CONTRIBUTING.md + - LICENSE.txt + - NOTICE.txt + - README.md + - SECURITY.md + - SUPPORT.md parameters: - name: Service @@ -48,9 +56,17 @@ extends: # This is Necessary since FromSource runs hover around 60, # which is the default, and intermittently time out. TimeoutInMinutes: 90 - # Keep the docs path synchronized with pr.paths.exclude above. + # Keep these documentation paths synchronized with pr.paths.exclude above. ExcludePaths: - docs/ + - AGENTS.md + - CODE_OF_CONDUCT.md + - CONTRIBUTING.md + - LICENSE.txt + - NOTICE.txt + - README.md + - SECURITY.md + - SUPPORT.md - eng/versioning/external_dependencies.txt - eng/versioning/version_client.txt - eng/versioning/version_java_files.txt diff --git a/eng/scripts/Test-RootDocumentationExclusions.ps1 b/eng/scripts/Test-RootDocumentationExclusions.ps1 new file mode 100644 index 0000000000000..5fa1b0e9ed6b4 --- /dev/null +++ b/eng/scripts/Test-RootDocumentationExclusions.ps1 @@ -0,0 +1,116 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +<# +.SYNOPSIS +Rejects tracked paths that collide with the Java PR pipeline's root document exclusions. + +.DESCRIPTION +ExcludePaths currently uses prefix matching. Until the shared matcher is hardened, +only the eight reviewed, exactly cased root documents may match those prefixes. +Check Spelling runs this guard on every supported PR, independently of changed file types. + +.PARAMETER RepositoryRoot +Repository to inventory with git ls-files. Defaults to this script's repository. + +.PARAMETER TrackedPaths +Full repository-relative paths to check instead of querying Git, for diagnostics and tests. +#> + +[CmdletBinding(DefaultParameterSetName = 'Repository')] +param( + [Parameter(ParameterSetName = 'Repository')] + [string]$RepositoryRoot = (Join-Path $PSScriptRoot '..' '..'), + + [Parameter(Mandatory = $true, ParameterSetName = 'Paths')] + [AllowEmptyCollection()] + [AllowEmptyString()] + [string[]]$TrackedPaths +) + +Set-StrictMode -Version 3 +$ErrorActionPreference = 'Stop' + +$rootDocuments = @( + 'AGENTS.md', + 'CODE_OF_CONDUCT.md', + 'CONTRIBUTING.md', + 'LICENSE.txt', + 'NOTICE.txt', + 'README.md', + 'SECURITY.md', + 'SUPPORT.md' +) + +if ($PSCmdlet.ParameterSetName -eq 'Repository') { + # Preserve NUL delimiters: line-based native output can misread quoted or multiline Git paths. + $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = (Get-Command git -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.StandardOutputEncoding = [System.Text.Encoding]::UTF8 + foreach ($argument in @('-C', $RepositoryRoot, 'ls-files', '--full-name', '-z', '--', ':/')) { + $startInfo.ArgumentList.Add($argument) + } + + $process = [System.Diagnostics.Process]::Start($startInfo) + try { + $outputTask = $process.StandardOutput.ReadToEndAsync() + $errorTask = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $output = $outputTask.GetAwaiter().GetResult() + $errorOutput = $errorTask.GetAwaiter().GetResult() + if ($process.ExitCode -ne 0) { + throw "Cannot inventory tracked paths in '$RepositoryRoot': git exited $($process.ExitCode). $errorOutput" + } + if (-not $output.EndsWith("`0", [System.StringComparison]::Ordinal)) { + throw "Cannot inventory tracked paths in '$RepositoryRoot': Git returned empty or invalid NUL-delimited output." + } + $TrackedPaths = $output.Substring(0, $output.Length - 1).Split([char]0) + } + finally { + $process.Dispose() + } +} + +if ($TrackedPaths.Count -eq 0) { + throw 'Cannot validate root document exclusions without a tracked-path inventory.' +} +if ($TrackedPaths -contains $null -or $TrackedPaths -contains '') { + throw 'Cannot validate root document exclusions: the tracked-path inventory contains an empty path.' +} + +# Native array filtering avoids comparing every SDK path in PowerShell. Keep unusual root +# characters too, since culture-aware StartsWith can ignore characters such as soft hyphens. +$prefixPattern = ($rootDocuments | ForEach-Object { [regex]::Escape($_) }) -join '|' +$candidatePattern = [regex]::new( + '^(?:' + $prefixPattern + '|[^/]*[^\x20-\x7e])', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor + [System.Text.RegularExpressions.RegexOptions]::CultureInvariant +) +$candidates = @($TrackedPaths -match $candidatePattern) + +$collisions = @( + foreach ($path in $candidates) { + foreach ($document in $rootDocuments) { + if ($path.Equals($document, [System.StringComparison]::Ordinal)) { + continue + } + # Match the shared comparison and reject ASCII case variants regardless of the runner's culture. + if ($path.StartsWith($document, [System.StringComparison]::CurrentCultureIgnoreCase) -or + $path.StartsWith($document, [System.StringComparison]::OrdinalIgnoreCase)) { + "$(ConvertTo-Json -InputObject $path -Compress) matches exclusion '$document'" + } + } + } +) + +if ($collisions.Count -gt 0) { + throw ("Root documentation exclusion collisions:`n" + ($collisions -join "`n") + + "`nRename the colliding paths, or remove their matching root document entries from both " + + "pr.paths.exclude and ExcludePaths in eng/pipelines/pullrequest.yml before adding these paths. " + + 'Prefix matching could otherwise skip Java package validation.') +} + +Write-Host "Checked $($TrackedPaths.Count) tracked paths ($($candidates.Count) root candidates): no root documentation exclusion collisions." diff --git a/eng/scripts/tests/PullRequest-Trigger.tests.ps1 b/eng/scripts/tests/PullRequest-Trigger.tests.ps1 index 4fd171960d922..3642399adb70d 100644 --- a/eng/scripts/tests/PullRequest-Trigger.tests.ps1 +++ b/eng/scripts/tests/PullRequest-Trigger.tests.ps1 @@ -3,6 +3,8 @@ BeforeAll { $script:RepositoryRoot = Resolve-Path (Join-Path $PSScriptRoot '..' '..' '..') + . (Join-Path $script:RepositoryRoot 'eng/common/scripts/Package-Properties.ps1') + . (Join-Path $script:RepositoryRoot 'eng/scripts/Language-Settings.ps1') function Get-YamlSequence { param( @@ -72,10 +74,36 @@ BeforeAll { 'hotfix/*', 'release/*' ) + $script:RootDocuments = @( + 'AGENTS.md', + 'CODE_OF_CONDUCT.md', + 'CONTRIBUTING.md', + 'LICENSE.txt', + 'NOTICE.txt', + 'README.md', + 'SECURITY.md', + 'SUPPORT.md' + ) $script:ExpectedStaticTriggerExclusions = @( '.github/skills/azsdk-common-*/**', 'docs/**' + ) + $script:RootDocuments + $script:ExpectedPackageExclusions = @('docs/') + $script:RootDocuments + @( + 'eng/versioning/external_dependencies.txt', + 'eng/versioning/version_client.txt', + 'eng/versioning/version_java_files.txt', + 'sdk/batch/microsoft-azure-batch/', + 'sdk/boms/', + 'sdk/cosmos/', + 'sdk/e2e/', + 'sdk/eventhubs/microsoft-azure-eventhubs/', + 'sdk/eventhubs/microsoft-azure-eventhubs-eph/', + 'sdk/servicebus/microsoft-azure-servicebus/', + 'sdk/spring/' ) + $script:TriggerExclusions = Get-YamlSequence -Path $script:PullRequestPath -KeyPath @('pr', 'paths', 'exclude') + $script:PackageExclusions = Get-YamlSequence ` + -Path $script:PullRequestPath -KeyPath @('extends', 'parameters', 'ExcludePaths') } Describe 'Pull request trigger contracts' -Tag 'UnitTest' { @@ -104,7 +132,7 @@ Describe 'Pull request trigger contracts' -Tag 'UnitTest' { Compare-Object ` -ReferenceObject $script:ExpectedStaticTriggerExclusions ` - -DifferenceObject $actualExclusions | + -DifferenceObject $actualExclusions -CaseSensitive | Should -BeNullOrEmpty @($actualExclusions | Where-Object { $_ -in @('**/*.md', '**/*.txt') }) | Should -BeNullOrEmpty @@ -112,12 +140,126 @@ Describe 'Pull request trigger contracts' -Tag 'UnitTest' { Should -BeNullOrEmpty } - It 'keeps static documentation exclusions available to mixed-PR classification' { - $actualExcludePaths = Get-YamlSequence ` - -Path $script:PullRequestPath ` - -KeyPath @('extends', 'parameters', 'ExcludePaths') + It 'mirrors root documents and preserves existing package-selection exclusions' { + Compare-Object ` + -ReferenceObject $script:ExpectedPackageExclusions ` + -DifferenceObject $script:PackageExclusions -CaseSensitive | + Should -BeNullOrEmpty + } + + It 'excludes each reviewed root document in both package-selection consumers' { + foreach ($document in $script:RootDocuments) { + @($script:TriggerExclusions | Where-Object { $document -clike $_ }) | + Should -Be @($document) + Update-TargetedFilesForExclude @($document) $script:PackageExclusions | + Should -BeNullOrEmpty + + $diff = [pscustomobject]@{ + ChangedFiles = @($document) + DeletedFiles = @() + ExcludePaths = $script:PackageExclusions + } + $template = [pscustomobject]@{ Name = 'template'; ServiceDirectory = 'template' } + Get-java-AdditionalValidationPackagesFromPackageSet ` + -LocatedPackages @() -diffObj $diff -AllPkgProps @($template) | + Should -BeNullOrEmpty + } + } + + It 'preserves trigger and package selection for ' -TestCases @( + @{ Path = 'sdk/example/README.md' } + @{ Path = 'sdk/example/example/README.md' } + @{ Path = 'sdk/example/example/CHANGELOG.md' } + @{ Path = 'sdk/example/example/LICENSE.txt' } + @{ Path = 'sdk/example/example/swagger/README.md' } + @{ Path = 'sdk/example/example/src/main/resources/NOTICE.txt' } + @{ Path = 'sdk/example/example/src/test/resources/README.md' } + @{ Path = 'sdk/example/example/src/test-shared/AGENTS.md' } + @{ Path = 'sdk/example/example/src/main/java/Example.java' } + @{ Path = 'sdk/example/example/tsp-location.yaml' } + @{ Path = 'eng/scripts/build.ps1' } + @{ Path = 'pom.xml' } + @{ Path = 'README.txt' } + @{ Path = 'README.template.md' } + @{ Path = 'NOTICE.md' } + @{ Path = 'unknown.md' } + ) { + param($Path) + + @($script:TriggerExclusions | Where-Object { $Path -clike $_ }) | Should -BeNullOrEmpty + Update-TargetedFilesForExclude @($Path) $script:PackageExclusions | Should -Be @($Path) + } + + It 'keeps functional paths in mixed PRs after filtering root documents' { + $functionalPaths = @('pom.xml', 'sdk/example/README.md', 'sdk/example/example/src/test/resources/NOTICE.txt') + $diff = [pscustomobject]@{ + ChangedFiles = $script:RootDocuments + $functionalPaths + DeletedFiles = @() + ExcludePaths = $script:PackageExclusions + } + $selectedPaths = Update-TargetedFilesForExclude $diff.ChangedFiles $diff.ExcludePaths + $selectedPaths | Should -Be $functionalPaths + + $packages = @( + [pscustomobject]@{ Name = 'template'; ServiceDirectory = 'template'; IncludedForValidation = $false } + [pscustomobject]@{ Name = 'example'; ServiceDirectory = 'example'; IncludedForValidation = $false } + ) + $additional = @(Get-java-AdditionalValidationPackagesFromPackageSet ` + -LocatedPackages @() -diffObj $diff -AllPkgProps $packages) + $additional.Name | Should -Be @('template', 'example') + } + + It 'preserves deleted functional paths in mixed PRs' { + $diff = [pscustomobject]@{ + ChangedFiles = @('README.md') + DeletedFiles = @('NOTICE.txt', 'sdk/example/README.md') + ExcludePaths = $script:PackageExclusions + } + $selectedPaths = Update-TargetedFilesForExclude ` + ($diff.ChangedFiles + $diff.DeletedFiles) $diff.ExcludePaths + $selectedPaths | Should -Be @('sdk/example/README.md') + + $package = [pscustomobject]@{ + Name = 'example' + ServiceDirectory = 'example' + IncludedForValidation = $false + } + $additional = @(Get-java-AdditionalValidationPackagesFromPackageSet ` + -LocatedPackages @() -diffObj $diff -AllPkgProps @($package)) + $additional.Name | Should -Be @('example') + } + + It 'guards longer-prefix paths that the existing package matcher would otherwise exclude' { + $guardPath = Join-Path $script:RepositoryRoot 'eng/scripts/Test-RootDocumentationExclusions.ps1' + foreach ($document in $script:RootDocuments) { + $collision = "$document.template" + @($script:TriggerExclusions | Where-Object { $collision -clike $_ }) | Should -BeNullOrEmpty + Update-TargetedFilesForExclude @($collision) $script:PackageExclusions | Should -BeNullOrEmpty + { & $guardPath -TrackedPaths @($collision) } | Should -Throw '*matches exclusion*' + } + } + + It 'keeps the collision guard in the existing unrestricted Check Spelling job' { + $workflow = Get-Content ` + -LiteralPath (Join-Path $script:RepositoryRoot '.github/workflows/check-spelling.yml') -Raw + $jobs = [regex]::Match($workflow, '(?ms)^jobs:\r?\n(.*)').Groups[1].Value + $steps = $workflow -split '(?m)^ - name: ' + $guardSteps = @($steps | Where-Object { $_ -match '^Check root documentation exclusions\r?\n' }) + $spellingSteps = @($steps | Where-Object { $_ -match '^Check spelling\r?\n' }) + + @([regex]::Matches($jobs, '(?m)^ [\w-]+:')).Count | Should -Be 1 + $workflow | Should -Match '(?m)^ check-spelling:\s*\r?\n name: Check Spelling\s*\r?\n runs-on: ubuntu-slim' + $workflow | Should -Not -Match '(?m)^\s+(paths|paths-ignore|continue-on-error|sparse-checkout):' + $jobs | Should -Not -Match '(?m)^ if:' + $guardSteps.Count | Should -Be 1 + $guardSteps[0] | Should -Match '(?m)^ if: \$\{\{ !cancelled\(\) \}\}\s*$' + $guardSteps[0] | Should -Match '(?m)^ shell: pwsh\s*$' + $guardSteps[0] | Should -Match '(?m)^ run: \./eng/scripts/Test-RootDocumentationExclusions\.ps1\s*$' - $actualExcludePaths | Should -Contain 'docs/' + $spellingSteps.Count | Should -Be 1 + $spellingSteps[0] | Should -Match '(?m)^ shell: pwsh\s*$' + $spellingSteps[0] | Should -Match ('(?s)run: >\s*\./eng/common/scripts/check-spelling-in-changed-files\.ps1\s*' + + '-CspellConfigPath \.vscode/cspell\.json\s*-ExitWithError\s*-SourceCommittish HEAD\s*-TargetCommittish HEAD\^') } It 'tracks the reviewed top-level docs file types' { diff --git a/eng/scripts/tests/RootDocumentationExclusions.tests.ps1 b/eng/scripts/tests/RootDocumentationExclusions.tests.ps1 new file mode 100644 index 0000000000000..623b17847616e --- /dev/null +++ b/eng/scripts/tests/RootDocumentationExclusions.tests.ps1 @@ -0,0 +1,214 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +BeforeAll { + $script:GuardPath = Join-Path $PSScriptRoot '..' 'Test-RootDocumentationExclusions.ps1' + $script:RepositoryRoot = Resolve-Path (Join-Path $PSScriptRoot '..' '..' '..') + + function Invoke-InventoryTestGit { + param([string]$RepositoryPath, [string[]]$Arguments) + + $output = & git -C $RepositoryPath @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Git fixture command failed ($Arguments): $output" + } + return $output + } +} + +Describe 'Root documentation exclusion guard' -Tag 'UnitTest' { + It 'accepts the exact root document but rejects longer prefixes for ' -TestCases @( + @{ Document = 'AGENTS.md' } + @{ Document = 'CODE_OF_CONDUCT.md' } + @{ Document = 'CONTRIBUTING.md' } + @{ Document = 'LICENSE.txt' } + @{ Document = 'NOTICE.txt' } + @{ Document = 'README.md' } + @{ Document = 'SECURITY.md' } + @{ Document = 'SUPPORT.md' } + ) { + param($Document) + + { & $script:GuardPath -TrackedPaths @($Document) } | Should -Not -Throw + { & $script:GuardPath -TrackedPaths @("$Document.template") } | + Should -Throw "*$Document.template*matches exclusion '$Document'*" + { & $script:GuardPath -TrackedPaths @("$Document/src/Example.java") } | + Should -Throw "*$Document/src/Example.java*matches exclusion '$Document'*" + } + + It 'does not confuse nested documents, protected inputs, or safe sibling names with root prefixes' { + $paths = @( + 'sdk/example/README.md', + 'sdk/example/example/README.md', + 'sdk/example/example/CHANGELOG.md', + 'sdk/example/example/README.md.template', + 'sdk/example/example/src/main/resources/LICENSE.txt', + 'sdk/example/example/src/test/resources/NOTICE.txt', + 'sdk/example/example/src/test-shared/AGENTS.md', + 'sdk/example/example/swagger/README.md', + 'sdk/example/example/src/main/java/Example.java', + 'sdk/example/example/tsp-location.yaml', + 'docs/README.md', + 'eng/scripts/README.md', + 'README.txt', + 'README.template.md', + 'NOTICE.md', + 'AGENTS.json', + 'unknown.md' + ) + + { & $script:GuardPath -TrackedPaths $paths } | Should -Not -Throw + } + + It 'rejects case-only aliases and mixed-case prefixes for ' -TestCases @( + @{ Path = 'readme.md' } + @{ Path = 'License.txt' } + @{ Path = 'readme.md.template' } + @{ Path = 'README.MD/src/Example.java' } + @{ Path = 'nOtIcE.TxT.template' } + ) { + param($Path) + + { & $script:GuardPath -TrackedPaths @($Path) } | Should -Throw '*matches exclusion*' + } + + It 'rejects ASCII case variants even in a culture with different casing rules' { + $previousCulture = [System.Globalization.CultureInfo]::CurrentCulture + try { + [System.Globalization.CultureInfo]::CurrentCulture = [System.Globalization.CultureInfo]::GetCultureInfo('tr-TR') + { & $script:GuardPath -TrackedPaths @('license.txt.template') } | Should -Throw '*matches exclusion*' + } + finally { + [System.Globalization.CultureInfo]::CurrentCulture = $previousCulture + } + } + + It 'retains culture-equivalent Unicode root prefixes with suffix ' -TestCases @( + @{ Suffix = '.template' } + @{ Suffix = '/src/Example.java' } + ) { + param($Suffix) + + $previousCulture = [System.Globalization.CultureInfo]::CurrentCulture + try { + [System.Globalization.CultureInfo]::CurrentCulture = [System.Globalization.CultureInfo]::GetCultureInfo('en-US') + $path = 'READ' + [char]0x00AD + 'ME.md' + $Suffix + $path.StartsWith('README.md', [System.StringComparison]::CurrentCultureIgnoreCase) | Should -BeTrue + { & $script:GuardPath -TrackedPaths @($path) } | Should -Throw "*matches exclusion 'README.md'*" + } + finally { + [System.Globalization.CultureInfo]::CurrentCulture = $previousCulture + } + } + + It 'reports every collision with its exact path and an actionable fix' { + $message = try { + & $script:GuardPath -TrackedPaths @('README.md.template', 'SUPPORT.md/tools.ps1', 'pom.xml') + } + catch { + $_.Exception.Message + } + + $message | Should -Match '"README\.md\.template" matches exclusion ''README\.md''' + $message | Should -Match '"SUPPORT\.md/tools\.ps1" matches exclusion ''SUPPORT\.md''' + $message | Should -Match 'both pr\.paths\.exclude and ExcludePaths in eng/pipelines/pullrequest\.yml' + } + + It 'fails closed without an inventory or with an empty inventory entry' { + { & $script:GuardPath -TrackedPaths @() } | Should -Throw '*without a tracked-path inventory*' + { & $script:GuardPath -TrackedPaths @('README.md', '') } | Should -Throw '*contains an empty path*' + { & $script:GuardPath -TrackedPaths @('README.md', $null) } | Should -Throw + } + + It 'finds no collisions in the current tracked repository tree' { + { & $script:GuardPath -RepositoryRoot $script:RepositoryRoot } | Should -Not -Throw + } +} + +Describe 'Root documentation Git inventory' -Tag 'UnitTest' { + BeforeEach { + $repositoryPath = Join-Path $TestDrive ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory -Path $repositoryPath | Out-Null + Invoke-InventoryTestGit $repositoryPath @('init', '--quiet') | Out-Null + } + + It 'fails closed when Git fails or the repository has no tracked paths' { + { & $script:GuardPath -RepositoryRoot (Join-Path $repositoryPath 'missing') } | + Should -Throw '*git exited*' + { & $script:GuardPath -RepositoryRoot $repositoryPath } | + Should -Throw '*empty or invalid NUL-delimited output*' + } + + It 'inventories newly tracked non-Markdown files without relying on the changed-file spelling list' { + Set-Content -LiteralPath (Join-Path $repositoryPath 'README.md') -Value 'Reviewed root document' + Set-Content -LiteralPath (Join-Path $repositoryPath 'README.md.template') -Value 'Functional input' + Invoke-InventoryTestGit $repositoryPath @('add', '--', 'README.md') | Out-Null + + { & $script:GuardPath -RepositoryRoot $repositoryPath } | Should -Not -Throw + & pwsh -NoLogo -NoProfile -NonInteractive -File $script:GuardPath -RepositoryRoot $repositoryPath + $LASTEXITCODE | Should -Be 0 + + Invoke-InventoryTestGit $repositoryPath @('add', '--', 'README.md.template') | Out-Null + { & $script:GuardPath -RepositoryRoot $repositoryPath } | Should -Throw '*README.md.template*' + $PSNativeCommandUseErrorActionPreference = $false + $output = & pwsh -NoLogo -NoProfile -NonInteractive ` + -File $script:GuardPath -RepositoryRoot $repositoryPath 2>&1 + $LASTEXITCODE | Should -Be 1 + ($output -join "`n") | Should -Match 'README\.md\.template' + } + + It 'uses the full root-relative inventory even when invoked on a subdirectory' { + $nestedDirectory = Join-Path $repositoryPath 'sdk' 'example' + New-Item -ItemType Directory -Path $nestedDirectory -Force | Out-Null + Set-Content -LiteralPath (Join-Path $nestedDirectory 'README.md') -Value 'Package document' + Invoke-InventoryTestGit $repositoryPath @('add', '--', 'sdk/example/README.md') | Out-Null + { & $script:GuardPath -RepositoryRoot $nestedDirectory } | Should -Not -Throw + + Set-Content -LiteralPath (Join-Path $repositoryPath 'README.md.template') -Value 'Functional input' + Invoke-InventoryTestGit $repositoryPath @('add', '--', 'README.md.template') | Out-Null + { & $script:GuardPath -RepositoryRoot $nestedDirectory } | Should -Throw '*README.md.template*' + } + + It 'rejects a tracked descendant of the prefix-named root directory ' -TestCases @( + @{ Directory = 'README.md' } + @{ Directory = 'README.md.template' } + @{ Directory = 'rEaDmE.Md.template' } + ) { + param($Directory) + + $sourceDirectory = Join-Path $repositoryPath $Directory 'src' + New-Item -ItemType Directory -Path $sourceDirectory -Force | Out-Null + Set-Content -LiteralPath (Join-Path $sourceDirectory 'Example.java') -Value 'Functional input' + $path = "$Directory/src/Example.java" + Invoke-InventoryTestGit $repositoryPath @('add', '--', $path) | Out-Null + + { & $script:GuardPath -RepositoryRoot $repositoryPath } | Should -Throw "*$path*" + } + + It 'preserves unusual Git paths in collision diagnostics for ' -TestCases @( + @{ Path = 'README.md [fixture].template' } + @{ Path = 'README.md"quoted.template' } + @{ Path = "README.md`nmultiline.template" } + @{ Path = ('READ' + [char]0x00AD + 'ME.md.template') } + @{ Path = ('READ' + [char]0x00AD + 'ME.md/src/Example.java') } + ) { + param($Path) + + $fixture = Join-Path $repositoryPath 'fixture.txt' + Set-Content -LiteralPath $fixture -Value 'Functional input' + $blob = Invoke-InventoryTestGit $repositoryPath @('hash-object', '-w', '--', $fixture) + # Index-only fixtures also exercise names that Windows cannot materialize. + Invoke-InventoryTestGit $repositoryPath @( + '-c', 'core.protectNTFS=false', 'update-index', '--add', '--cacheinfo', '100644', $blob, $Path + ) | Out-Null + + $message = try { + & $script:GuardPath -RepositoryRoot $repositoryPath + } + catch { + $_.Exception.Message + } + $message | Should -Match ([regex]::Escape((ConvertTo-Json -InputObject $Path -Compress))) + $message | Should -Match "matches exclusion 'README.md'" + } +} From 346aee7bf59948127ccae789641674db6c12ba9c Mon Sep 17 00:00:00 2001 From: Victor Colin Amador Date: Tue, 15 Sep 2026 14:18:01 -0700 Subject: [PATCH 2/2] Remove the root-document collision guard --- .github/workflows/check-spelling.yml | 6 - eng/README.md | 15 +- .../Test-RootDocumentationExclusions.ps1 | 116 ---------- .../tests/PullRequest-Trigger.tests.ps1 | 18 +- .../RootDocumentationExclusions.tests.ps1 | 214 ------------------ 5 files changed, 5 insertions(+), 364 deletions(-) delete mode 100644 eng/scripts/Test-RootDocumentationExclusions.ps1 delete mode 100644 eng/scripts/tests/RootDocumentationExclusions.tests.ps1 diff --git a/.github/workflows/check-spelling.yml b/.github/workflows/check-spelling.yml index 2e37b26afca91..ff758f223aa2e 100644 --- a/.github/workflows/check-spelling.yml +++ b/.github/workflows/check-spelling.yml @@ -37,9 +37,3 @@ jobs: -ExitWithError -SourceCommittish HEAD -TargetCommittish HEAD^ - - - name: Check root documentation exclusions - # Inventory all tracked paths, even when spelling fails or checks no files. - if: ${{ !cancelled() }} - shell: pwsh - run: ./eng/scripts/Test-RootDocumentationExclusions.ps1 diff --git a/eng/README.md b/eng/README.md index 8741edd16a19c..c6e50661b0f30 100644 --- a/eng/README.md +++ b/eng/README.md @@ -20,23 +20,16 @@ SDK-package documents, CHANGELOGs, source/resources, and unknown paths gain no t Build/Analyze orchestration and the existing test-matrix classifier are unchanged. The required **Check Spelling** job still checks all supported PR branches without path filters, using the existing -CSpell configuration and ignore rules. In the same job, [Test-RootDocumentationExclusions.ps1](scripts/Test-RootDocumentationExclusions.ps1) -checks the entire tracked-path inventory, even if spelling fails or has no files to check. A native regex prefilter -limits detailed comparisons to root candidates, including unusual root characters needed for culture-aware matching. -This temporary guard rejects longer prefixes (such as `README.md.template` or `README.md/src/Example.java`) and -case-only aliases because package selection still uses prefix matching. Nested names such as `sdk/example/README.md` -do not collide with root exclusions. Git inventory failures also fail the job. Rename a colliding path or remove -its matching root-document exclusion from both lists before adding it. Shared matcher hardening remains an upstream -`azure-sdk-tools` change; do not patch `eng/common` locally. **Verify Links** remains a separate, unchanged workflow. +CSpell configuration and ignore rules. **Verify Links** remains a separate, unchanged workflow. +Package selection retains the existing `ExcludePaths` prefix-matching behavior. -Run the guard and its regression tests with PowerShell 7, Git, and the CI-declared Pester 5.7.1 (no YAML module required): +Run the trigger and classifier regression tests with PowerShell 7, Git, and the CI-declared Pester 5.7.1 +(no YAML module required): ```powershell -./eng/scripts/Test-RootDocumentationExclusions.ps1 Import-Module Pester -RequiredVersion 5.7.1 Invoke-Pester -Path @( 'eng/scripts/tests/PullRequest-Trigger.tests.ps1', - 'eng/scripts/tests/RootDocumentationExclusions.tests.ps1', 'eng/scripts/tests/Classify-PRChanges.tests.ps1' ) -Tag UnitTest -Output Detailed ``` diff --git a/eng/scripts/Test-RootDocumentationExclusions.ps1 b/eng/scripts/Test-RootDocumentationExclusions.ps1 deleted file mode 100644 index 5fa1b0e9ed6b4..0000000000000 --- a/eng/scripts/Test-RootDocumentationExclusions.ps1 +++ /dev/null @@ -1,116 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -<# -.SYNOPSIS -Rejects tracked paths that collide with the Java PR pipeline's root document exclusions. - -.DESCRIPTION -ExcludePaths currently uses prefix matching. Until the shared matcher is hardened, -only the eight reviewed, exactly cased root documents may match those prefixes. -Check Spelling runs this guard on every supported PR, independently of changed file types. - -.PARAMETER RepositoryRoot -Repository to inventory with git ls-files. Defaults to this script's repository. - -.PARAMETER TrackedPaths -Full repository-relative paths to check instead of querying Git, for diagnostics and tests. -#> - -[CmdletBinding(DefaultParameterSetName = 'Repository')] -param( - [Parameter(ParameterSetName = 'Repository')] - [string]$RepositoryRoot = (Join-Path $PSScriptRoot '..' '..'), - - [Parameter(Mandatory = $true, ParameterSetName = 'Paths')] - [AllowEmptyCollection()] - [AllowEmptyString()] - [string[]]$TrackedPaths -) - -Set-StrictMode -Version 3 -$ErrorActionPreference = 'Stop' - -$rootDocuments = @( - 'AGENTS.md', - 'CODE_OF_CONDUCT.md', - 'CONTRIBUTING.md', - 'LICENSE.txt', - 'NOTICE.txt', - 'README.md', - 'SECURITY.md', - 'SUPPORT.md' -) - -if ($PSCmdlet.ParameterSetName -eq 'Repository') { - # Preserve NUL delimiters: line-based native output can misread quoted or multiline Git paths. - $startInfo = [System.Diagnostics.ProcessStartInfo]::new() - $startInfo.FileName = (Get-Command git -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source - $startInfo.UseShellExecute = $false - $startInfo.RedirectStandardOutput = $true - $startInfo.RedirectStandardError = $true - $startInfo.StandardOutputEncoding = [System.Text.Encoding]::UTF8 - foreach ($argument in @('-C', $RepositoryRoot, 'ls-files', '--full-name', '-z', '--', ':/')) { - $startInfo.ArgumentList.Add($argument) - } - - $process = [System.Diagnostics.Process]::Start($startInfo) - try { - $outputTask = $process.StandardOutput.ReadToEndAsync() - $errorTask = $process.StandardError.ReadToEndAsync() - $process.WaitForExit() - $output = $outputTask.GetAwaiter().GetResult() - $errorOutput = $errorTask.GetAwaiter().GetResult() - if ($process.ExitCode -ne 0) { - throw "Cannot inventory tracked paths in '$RepositoryRoot': git exited $($process.ExitCode). $errorOutput" - } - if (-not $output.EndsWith("`0", [System.StringComparison]::Ordinal)) { - throw "Cannot inventory tracked paths in '$RepositoryRoot': Git returned empty or invalid NUL-delimited output." - } - $TrackedPaths = $output.Substring(0, $output.Length - 1).Split([char]0) - } - finally { - $process.Dispose() - } -} - -if ($TrackedPaths.Count -eq 0) { - throw 'Cannot validate root document exclusions without a tracked-path inventory.' -} -if ($TrackedPaths -contains $null -or $TrackedPaths -contains '') { - throw 'Cannot validate root document exclusions: the tracked-path inventory contains an empty path.' -} - -# Native array filtering avoids comparing every SDK path in PowerShell. Keep unusual root -# characters too, since culture-aware StartsWith can ignore characters such as soft hyphens. -$prefixPattern = ($rootDocuments | ForEach-Object { [regex]::Escape($_) }) -join '|' -$candidatePattern = [regex]::new( - '^(?:' + $prefixPattern + '|[^/]*[^\x20-\x7e])', - [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor - [System.Text.RegularExpressions.RegexOptions]::CultureInvariant -) -$candidates = @($TrackedPaths -match $candidatePattern) - -$collisions = @( - foreach ($path in $candidates) { - foreach ($document in $rootDocuments) { - if ($path.Equals($document, [System.StringComparison]::Ordinal)) { - continue - } - # Match the shared comparison and reject ASCII case variants regardless of the runner's culture. - if ($path.StartsWith($document, [System.StringComparison]::CurrentCultureIgnoreCase) -or - $path.StartsWith($document, [System.StringComparison]::OrdinalIgnoreCase)) { - "$(ConvertTo-Json -InputObject $path -Compress) matches exclusion '$document'" - } - } - } -) - -if ($collisions.Count -gt 0) { - throw ("Root documentation exclusion collisions:`n" + ($collisions -join "`n") + - "`nRename the colliding paths, or remove their matching root document entries from both " + - "pr.paths.exclude and ExcludePaths in eng/pipelines/pullrequest.yml before adding these paths. " + - 'Prefix matching could otherwise skip Java package validation.') -} - -Write-Host "Checked $($TrackedPaths.Count) tracked paths ($($candidates.Count) root candidates): no root documentation exclusion collisions." diff --git a/eng/scripts/tests/PullRequest-Trigger.tests.ps1 b/eng/scripts/tests/PullRequest-Trigger.tests.ps1 index 3642399adb70d..3eb0eb97298b3 100644 --- a/eng/scripts/tests/PullRequest-Trigger.tests.ps1 +++ b/eng/scripts/tests/PullRequest-Trigger.tests.ps1 @@ -229,33 +229,17 @@ Describe 'Pull request trigger contracts' -Tag 'UnitTest' { $additional.Name | Should -Be @('example') } - It 'guards longer-prefix paths that the existing package matcher would otherwise exclude' { - $guardPath = Join-Path $script:RepositoryRoot 'eng/scripts/Test-RootDocumentationExclusions.ps1' - foreach ($document in $script:RootDocuments) { - $collision = "$document.template" - @($script:TriggerExclusions | Where-Object { $collision -clike $_ }) | Should -BeNullOrEmpty - Update-TargetedFilesForExclude @($collision) $script:PackageExclusions | Should -BeNullOrEmpty - { & $guardPath -TrackedPaths @($collision) } | Should -Throw '*matches exclusion*' - } - } - - It 'keeps the collision guard in the existing unrestricted Check Spelling job' { + It 'keeps the required Check Spelling job unrestricted' { $workflow = Get-Content ` -LiteralPath (Join-Path $script:RepositoryRoot '.github/workflows/check-spelling.yml') -Raw $jobs = [regex]::Match($workflow, '(?ms)^jobs:\r?\n(.*)').Groups[1].Value $steps = $workflow -split '(?m)^ - name: ' - $guardSteps = @($steps | Where-Object { $_ -match '^Check root documentation exclusions\r?\n' }) $spellingSteps = @($steps | Where-Object { $_ -match '^Check spelling\r?\n' }) @([regex]::Matches($jobs, '(?m)^ [\w-]+:')).Count | Should -Be 1 $workflow | Should -Match '(?m)^ check-spelling:\s*\r?\n name: Check Spelling\s*\r?\n runs-on: ubuntu-slim' $workflow | Should -Not -Match '(?m)^\s+(paths|paths-ignore|continue-on-error|sparse-checkout):' $jobs | Should -Not -Match '(?m)^ if:' - $guardSteps.Count | Should -Be 1 - $guardSteps[0] | Should -Match '(?m)^ if: \$\{\{ !cancelled\(\) \}\}\s*$' - $guardSteps[0] | Should -Match '(?m)^ shell: pwsh\s*$' - $guardSteps[0] | Should -Match '(?m)^ run: \./eng/scripts/Test-RootDocumentationExclusions\.ps1\s*$' - $spellingSteps.Count | Should -Be 1 $spellingSteps[0] | Should -Match '(?m)^ shell: pwsh\s*$' $spellingSteps[0] | Should -Match ('(?s)run: >\s*\./eng/common/scripts/check-spelling-in-changed-files\.ps1\s*' + diff --git a/eng/scripts/tests/RootDocumentationExclusions.tests.ps1 b/eng/scripts/tests/RootDocumentationExclusions.tests.ps1 deleted file mode 100644 index 623b17847616e..0000000000000 --- a/eng/scripts/tests/RootDocumentationExclusions.tests.ps1 +++ /dev/null @@ -1,214 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -BeforeAll { - $script:GuardPath = Join-Path $PSScriptRoot '..' 'Test-RootDocumentationExclusions.ps1' - $script:RepositoryRoot = Resolve-Path (Join-Path $PSScriptRoot '..' '..' '..') - - function Invoke-InventoryTestGit { - param([string]$RepositoryPath, [string[]]$Arguments) - - $output = & git -C $RepositoryPath @Arguments 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "Git fixture command failed ($Arguments): $output" - } - return $output - } -} - -Describe 'Root documentation exclusion guard' -Tag 'UnitTest' { - It 'accepts the exact root document but rejects longer prefixes for ' -TestCases @( - @{ Document = 'AGENTS.md' } - @{ Document = 'CODE_OF_CONDUCT.md' } - @{ Document = 'CONTRIBUTING.md' } - @{ Document = 'LICENSE.txt' } - @{ Document = 'NOTICE.txt' } - @{ Document = 'README.md' } - @{ Document = 'SECURITY.md' } - @{ Document = 'SUPPORT.md' } - ) { - param($Document) - - { & $script:GuardPath -TrackedPaths @($Document) } | Should -Not -Throw - { & $script:GuardPath -TrackedPaths @("$Document.template") } | - Should -Throw "*$Document.template*matches exclusion '$Document'*" - { & $script:GuardPath -TrackedPaths @("$Document/src/Example.java") } | - Should -Throw "*$Document/src/Example.java*matches exclusion '$Document'*" - } - - It 'does not confuse nested documents, protected inputs, or safe sibling names with root prefixes' { - $paths = @( - 'sdk/example/README.md', - 'sdk/example/example/README.md', - 'sdk/example/example/CHANGELOG.md', - 'sdk/example/example/README.md.template', - 'sdk/example/example/src/main/resources/LICENSE.txt', - 'sdk/example/example/src/test/resources/NOTICE.txt', - 'sdk/example/example/src/test-shared/AGENTS.md', - 'sdk/example/example/swagger/README.md', - 'sdk/example/example/src/main/java/Example.java', - 'sdk/example/example/tsp-location.yaml', - 'docs/README.md', - 'eng/scripts/README.md', - 'README.txt', - 'README.template.md', - 'NOTICE.md', - 'AGENTS.json', - 'unknown.md' - ) - - { & $script:GuardPath -TrackedPaths $paths } | Should -Not -Throw - } - - It 'rejects case-only aliases and mixed-case prefixes for ' -TestCases @( - @{ Path = 'readme.md' } - @{ Path = 'License.txt' } - @{ Path = 'readme.md.template' } - @{ Path = 'README.MD/src/Example.java' } - @{ Path = 'nOtIcE.TxT.template' } - ) { - param($Path) - - { & $script:GuardPath -TrackedPaths @($Path) } | Should -Throw '*matches exclusion*' - } - - It 'rejects ASCII case variants even in a culture with different casing rules' { - $previousCulture = [System.Globalization.CultureInfo]::CurrentCulture - try { - [System.Globalization.CultureInfo]::CurrentCulture = [System.Globalization.CultureInfo]::GetCultureInfo('tr-TR') - { & $script:GuardPath -TrackedPaths @('license.txt.template') } | Should -Throw '*matches exclusion*' - } - finally { - [System.Globalization.CultureInfo]::CurrentCulture = $previousCulture - } - } - - It 'retains culture-equivalent Unicode root prefixes with suffix ' -TestCases @( - @{ Suffix = '.template' } - @{ Suffix = '/src/Example.java' } - ) { - param($Suffix) - - $previousCulture = [System.Globalization.CultureInfo]::CurrentCulture - try { - [System.Globalization.CultureInfo]::CurrentCulture = [System.Globalization.CultureInfo]::GetCultureInfo('en-US') - $path = 'READ' + [char]0x00AD + 'ME.md' + $Suffix - $path.StartsWith('README.md', [System.StringComparison]::CurrentCultureIgnoreCase) | Should -BeTrue - { & $script:GuardPath -TrackedPaths @($path) } | Should -Throw "*matches exclusion 'README.md'*" - } - finally { - [System.Globalization.CultureInfo]::CurrentCulture = $previousCulture - } - } - - It 'reports every collision with its exact path and an actionable fix' { - $message = try { - & $script:GuardPath -TrackedPaths @('README.md.template', 'SUPPORT.md/tools.ps1', 'pom.xml') - } - catch { - $_.Exception.Message - } - - $message | Should -Match '"README\.md\.template" matches exclusion ''README\.md''' - $message | Should -Match '"SUPPORT\.md/tools\.ps1" matches exclusion ''SUPPORT\.md''' - $message | Should -Match 'both pr\.paths\.exclude and ExcludePaths in eng/pipelines/pullrequest\.yml' - } - - It 'fails closed without an inventory or with an empty inventory entry' { - { & $script:GuardPath -TrackedPaths @() } | Should -Throw '*without a tracked-path inventory*' - { & $script:GuardPath -TrackedPaths @('README.md', '') } | Should -Throw '*contains an empty path*' - { & $script:GuardPath -TrackedPaths @('README.md', $null) } | Should -Throw - } - - It 'finds no collisions in the current tracked repository tree' { - { & $script:GuardPath -RepositoryRoot $script:RepositoryRoot } | Should -Not -Throw - } -} - -Describe 'Root documentation Git inventory' -Tag 'UnitTest' { - BeforeEach { - $repositoryPath = Join-Path $TestDrive ([guid]::NewGuid().ToString()) - New-Item -ItemType Directory -Path $repositoryPath | Out-Null - Invoke-InventoryTestGit $repositoryPath @('init', '--quiet') | Out-Null - } - - It 'fails closed when Git fails or the repository has no tracked paths' { - { & $script:GuardPath -RepositoryRoot (Join-Path $repositoryPath 'missing') } | - Should -Throw '*git exited*' - { & $script:GuardPath -RepositoryRoot $repositoryPath } | - Should -Throw '*empty or invalid NUL-delimited output*' - } - - It 'inventories newly tracked non-Markdown files without relying on the changed-file spelling list' { - Set-Content -LiteralPath (Join-Path $repositoryPath 'README.md') -Value 'Reviewed root document' - Set-Content -LiteralPath (Join-Path $repositoryPath 'README.md.template') -Value 'Functional input' - Invoke-InventoryTestGit $repositoryPath @('add', '--', 'README.md') | Out-Null - - { & $script:GuardPath -RepositoryRoot $repositoryPath } | Should -Not -Throw - & pwsh -NoLogo -NoProfile -NonInteractive -File $script:GuardPath -RepositoryRoot $repositoryPath - $LASTEXITCODE | Should -Be 0 - - Invoke-InventoryTestGit $repositoryPath @('add', '--', 'README.md.template') | Out-Null - { & $script:GuardPath -RepositoryRoot $repositoryPath } | Should -Throw '*README.md.template*' - $PSNativeCommandUseErrorActionPreference = $false - $output = & pwsh -NoLogo -NoProfile -NonInteractive ` - -File $script:GuardPath -RepositoryRoot $repositoryPath 2>&1 - $LASTEXITCODE | Should -Be 1 - ($output -join "`n") | Should -Match 'README\.md\.template' - } - - It 'uses the full root-relative inventory even when invoked on a subdirectory' { - $nestedDirectory = Join-Path $repositoryPath 'sdk' 'example' - New-Item -ItemType Directory -Path $nestedDirectory -Force | Out-Null - Set-Content -LiteralPath (Join-Path $nestedDirectory 'README.md') -Value 'Package document' - Invoke-InventoryTestGit $repositoryPath @('add', '--', 'sdk/example/README.md') | Out-Null - { & $script:GuardPath -RepositoryRoot $nestedDirectory } | Should -Not -Throw - - Set-Content -LiteralPath (Join-Path $repositoryPath 'README.md.template') -Value 'Functional input' - Invoke-InventoryTestGit $repositoryPath @('add', '--', 'README.md.template') | Out-Null - { & $script:GuardPath -RepositoryRoot $nestedDirectory } | Should -Throw '*README.md.template*' - } - - It 'rejects a tracked descendant of the prefix-named root directory ' -TestCases @( - @{ Directory = 'README.md' } - @{ Directory = 'README.md.template' } - @{ Directory = 'rEaDmE.Md.template' } - ) { - param($Directory) - - $sourceDirectory = Join-Path $repositoryPath $Directory 'src' - New-Item -ItemType Directory -Path $sourceDirectory -Force | Out-Null - Set-Content -LiteralPath (Join-Path $sourceDirectory 'Example.java') -Value 'Functional input' - $path = "$Directory/src/Example.java" - Invoke-InventoryTestGit $repositoryPath @('add', '--', $path) | Out-Null - - { & $script:GuardPath -RepositoryRoot $repositoryPath } | Should -Throw "*$path*" - } - - It 'preserves unusual Git paths in collision diagnostics for ' -TestCases @( - @{ Path = 'README.md [fixture].template' } - @{ Path = 'README.md"quoted.template' } - @{ Path = "README.md`nmultiline.template" } - @{ Path = ('READ' + [char]0x00AD + 'ME.md.template') } - @{ Path = ('READ' + [char]0x00AD + 'ME.md/src/Example.java') } - ) { - param($Path) - - $fixture = Join-Path $repositoryPath 'fixture.txt' - Set-Content -LiteralPath $fixture -Value 'Functional input' - $blob = Invoke-InventoryTestGit $repositoryPath @('hash-object', '-w', '--', $fixture) - # Index-only fixtures also exercise names that Windows cannot materialize. - Invoke-InventoryTestGit $repositoryPath @( - '-c', 'core.protectNTFS=false', 'update-index', '--add', '--cacheinfo', '100644', $blob, $Path - ) | Out-Null - - $message = try { - & $script:GuardPath -RepositoryRoot $repositoryPath - } - catch { - $_.Exception.Message - } - $message | Should -Match ([regex]::Escape((ConvertTo-Json -InputObject $Path -Compress))) - $message | Should -Match "matches exclusion 'README.md'" - } -}