microsoft/hve-core

Public

mirrored fromhttps://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
docs/659-role-based-docs-lifecycle-guides

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

scripts/tests/linting/Validate-MarkdownFrontmatter.Tests.ps1

1831lines · modecode

1#Requires -Modules Pester
2# Copyright (c) Microsoft Corporation.
3# SPDX-License-Identifier: MIT
4# Import module with 'using' to make PowerShell class types (FileTypeInfo, ValidationSummary, etc.) available at parse time
5using module ..\..\linting\Modules\FrontmatterValidation.psm1
6
7BeforeAll {
8 # Dot-source the main script
9 $scriptPath = Join-Path $PSScriptRoot '../../linting/Validate-MarkdownFrontmatter.ps1'
10 . $scriptPath
11
12 $mockPath = Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1'
13 Import-Module $mockPath -Force
14 $script:SchemaDir = Join-Path $PSScriptRoot '../../linting/schemas'
15 $script:FixtureDir = Join-Path $PSScriptRoot '../Fixtures/Frontmatter'
16 $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../../..')).Path
17}
18
19#region Get-FileTypeInfo Tests
20
21Describe 'Get-FileTypeInfo' -Tag 'Unit' {
22 BeforeAll {
23 # Create temporary test files for FileInfo objects
24 $script:TempTestDir = Join-Path ([System.IO.Path]::GetTempPath()) "FrontmatterTests_$([guid]::NewGuid().ToString('N'))"
25 New-Item -ItemType Directory -Path $script:TempTestDir -Force | Out-Null
26
27 # Create subdirectories to simulate repo structure
28 @(
29 'docs/guide',
30 '.github/instructions',
31 '.github/prompts',
32 '.github/chatmodes',
33 '.devcontainer',
34 '.vscode',
35 'random/path'
36 ) | ForEach-Object {
37 New-Item -ItemType Directory -Path (Join-Path $script:TempTestDir $_) -Force | Out-Null
38 }
39 }
40
41 AfterAll {
42 if (Test-Path $script:TempTestDir) {
43 Remove-Item -Path $script:TempTestDir -Recurse -Force -ErrorAction SilentlyContinue
44 }
45 }
46
47 Context 'Root community files' {
48 It 'Identifies README.md as root community' {
49 $filePath = Join-Path $script:TempTestDir 'README.md'
50 Set-Content -Path $filePath -Value 'test'
51 $file = Get-Item $filePath
52 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
53 $result.GetType().Name | Should -Be 'FileTypeInfo'
54 $result.IsRootCommunityFile | Should -BeTrue
55 }
56
57 It 'Identifies CONTRIBUTING.md as root community' {
58 $filePath = Join-Path $script:TempTestDir 'CONTRIBUTING.md'
59 Set-Content -Path $filePath -Value 'test'
60 $file = Get-Item $filePath
61 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
62 $result.IsRootCommunityFile | Should -BeTrue
63 }
64
65 It 'Identifies CODE_OF_CONDUCT.md as root community' {
66 $filePath = Join-Path $script:TempTestDir 'CODE_OF_CONDUCT.md'
67 Set-Content -Path $filePath -Value 'test'
68 $file = Get-Item $filePath
69 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
70 $result.IsRootCommunityFile | Should -BeTrue
71 }
72
73 It 'Identifies SECURITY.md as root community' {
74 $filePath = Join-Path $script:TempTestDir 'SECURITY.md'
75 Set-Content -Path $filePath -Value 'test'
76 $file = Get-Item $filePath
77 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
78 $result.IsRootCommunityFile | Should -BeTrue
79 }
80
81 It 'Identifies SUPPORT.md as root community' {
82 $filePath = Join-Path $script:TempTestDir 'SUPPORT.md'
83 Set-Content -Path $filePath -Value 'test'
84 $file = Get-Item $filePath
85 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
86 $result.IsRootCommunityFile | Should -BeTrue
87 }
88 }
89
90 Context 'Documentation files' {
91 It 'Identifies docs/**/*.md as docs file' {
92 $filePath = Join-Path $script:TempTestDir 'docs/guide/readme.md'
93 Set-Content -Path $filePath -Value 'test'
94 $file = Get-Item $filePath
95 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
96 $result.IsDocsFile | Should -BeTrue
97 }
98
99 It 'Does not mark root README as docs file' {
100 $filePath = Join-Path $script:TempTestDir 'README.md'
101 Set-Content -Path $filePath -Value 'test'
102 $file = Get-Item $filePath
103 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
104 $result.IsDocsFile | Should -BeFalse
105 }
106 }
107
108 Context 'Instruction files' {
109 It 'Identifies *.instructions.md as instruction file' {
110 $filePath = Join-Path $script:TempTestDir '.github/instructions/test.instructions.md'
111 Set-Content -Path $filePath -Value 'test'
112 $file = Get-Item $filePath
113 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
114 $result.IsInstruction | Should -BeTrue
115 }
116 }
117
118 Context 'Prompt files' {
119 It 'Identifies *.prompt.md as prompt file' {
120 $filePath = Join-Path $script:TempTestDir '.github/prompts/build.prompt.md'
121 Set-Content -Path $filePath -Value 'test'
122 $file = Get-Item $filePath
123 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
124 $result.IsPrompt | Should -BeTrue
125 }
126 }
127
128 Context 'Chatmode files' {
129 It 'Identifies *.chatmode.md as chatmode file' {
130 $filePath = Join-Path $script:TempTestDir '.github/chatmodes/helper.chatmode.md'
131 Set-Content -Path $filePath -Value 'test'
132 $file = Get-Item $filePath
133 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
134 $result.IsChatMode | Should -BeTrue
135 }
136 }
137
138 Context 'Special locations' {
139 It 'Identifies .devcontainer README' {
140 $filePath = Join-Path $script:TempTestDir '.devcontainer/README.md'
141 Set-Content -Path $filePath -Value 'test'
142 $file = Get-Item $filePath
143 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
144 $result.IsDevContainer | Should -BeTrue
145 }
146
147 It 'Identifies .vscode README' {
148 $filePath = Join-Path $script:TempTestDir '.vscode/README.md'
149 Set-Content -Path $filePath -Value 'test'
150 $file = Get-Item $filePath
151 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
152 $result.IsVSCodeReadme | Should -BeTrue
153 }
154 }
155
156 Context 'Unknown file types' {
157 It 'Returns all false for random markdown file' {
158 $filePath = Join-Path $script:TempTestDir 'random/path/file.md'
159 Set-Content -Path $filePath -Value 'test'
160 $file = Get-Item $filePath
161 $result = Get-FileTypeInfo -File $file -RepoRoot $script:TempTestDir
162 $result.IsRootCommunityFile | Should -BeFalse
163 $result.IsDocsFile | Should -BeFalse
164 $result.IsInstruction | Should -BeFalse
165 $result.IsPrompt | Should -BeFalse
166 $result.IsChatMode | Should -BeFalse
167 }
168 }
169}
170
171#endregion
172
173#region Test-MarkdownFooter Tests
174
175Describe 'Test-MarkdownFooter' -Tag 'Unit' {
176 BeforeAll {
177 # Standard Copilot attribution footer
178 $script:ValidFooter = '🤖 Crafted with precision by ✨Copilot following brilliant human instruction, carefully refined by our team of discerning human reviewers.'
179 $script:ValidFooterAlternate = '🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.'
180 }
181
182 Context 'Valid footer patterns' {
183 It 'Returns true for standard Copilot attribution footer' {
184 $content = "# Document`n`nSome content here.`n`n$script:ValidFooter"
185 Test-MarkdownFooter -Content $content | Should -BeTrue
186 }
187
188 It 'Returns true for alternate footer with "then" phrasing' {
189 $content = "# Document`n`nContent.`n`n$script:ValidFooterAlternate"
190 Test-MarkdownFooter -Content $content | Should -BeTrue
191 }
192
193 It 'Returns true when footer has trailing period' {
194 $content = "Content`n`n🤖 Crafted with precision by ✨Copilot following brilliant human instruction, carefully refined by our team of discerning human reviewers."
195 Test-MarkdownFooter -Content $content | Should -BeTrue
196 }
197
198 It 'Returns true when footer has no trailing period' {
199 $content = "Content`n`n🤖 Crafted with precision by ✨Copilot following brilliant human instruction, carefully refined by our team of discerning human reviewers"
200 Test-MarkdownFooter -Content $content | Should -BeTrue
201 }
202 }
203
204 Context 'Missing footer' {
205 It 'Returns false for content without Copilot attribution' {
206 $content = 'Content without the attribution footer'
207 Test-MarkdownFooter -Content $content | Should -BeFalse
208 }
209
210 It 'Returns false for empty content' {
211 Test-MarkdownFooter -Content '' | Should -BeFalse
212 }
213
214 It 'Returns false for partial attribution text' {
215 $content = "Content`n`n🤖 Crafted with precision"
216 Test-MarkdownFooter -Content $content | Should -BeFalse
217 }
218 }
219
220 Context 'Footer variations and normalization' {
221 It 'Handles footer with extra whitespace between words' {
222 $content = "Content`n`n🤖 Crafted with precision by ✨Copilot following brilliant human instruction, carefully refined by our team of discerning human reviewers."
223 Test-MarkdownFooter -Content $content | Should -BeTrue
224 }
225
226 It 'Handles footer after multiple blank lines' {
227 $content = "Content`n`n`n`n$script:ValidFooter"
228 Test-MarkdownFooter -Content $content | Should -BeTrue
229 }
230 }
231}
232
233#endregion
234
235#region Initialize-JsonSchemaValidation Tests
236
237Describe 'Initialize-JsonSchemaValidation' -Tag 'Unit' {
238 Context 'Normal operation' {
239 It 'Returns true when JSON processing is available' {
240 $result = Initialize-JsonSchemaValidation
241 $result | Should -BeTrue
242 }
243
244 It 'Validates JSON can be parsed' {
245 # Function internally tests JSON parsing
246 $result = Initialize-JsonSchemaValidation
247 $result | Should -BeOfType [bool]
248 }
249 }
250
251 Context 'Error handling' {
252 It 'Returns false and warns when JSON parsing fails' {
253 # Arrange - Mock ConvertFrom-Json to throw an error
254 Mock ConvertFrom-Json { throw "Simulated JSON parse error" }
255
256 # Act
257 $result = Initialize-JsonSchemaValidation -WarningVariable warnings -WarningAction SilentlyContinue
258
259 # Assert
260 $result | Should -BeFalse
261 }
262
263 It 'Warning message contains error details on exception' {
264 # Arrange - Mock ConvertFrom-Json to throw specific error
265 Mock ConvertFrom-Json { throw "Detailed parse failure" }
266
267 # Act
268 $null = Initialize-JsonSchemaValidation -WarningVariable warnings 3>$null
269
270 # Assert - Warning should contain the error context
271 $warnings | Should -Not -BeNullOrEmpty
272 $warnings[0] | Should -Match 'Error initializing schema validation'
273 }
274
275 It 'Handles null result from ConvertFrom-Json' {
276 # Arrange - Mock ConvertFrom-Json to return null
277 Mock ConvertFrom-Json { return $null }
278
279 # Act
280 $result = Initialize-JsonSchemaValidation
281
282 # Assert
283 $result | Should -BeFalse
284 }
285 }
286}
287
288#endregion
289
290#region Get-SchemaForFile Tests
291
292Describe 'Get-SchemaForFile' -Tag 'Unit' {
293 Context 'Schema mapping' {
294 It 'Returns docs schema for docs files' {
295 $result = Get-SchemaForFile -FilePath 'docs/guide/readme.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
296 $result | Should -Match 'docs-frontmatter\.schema\.json'
297 }
298
299 It 'Returns instruction schema for instruction files' {
300 $result = Get-SchemaForFile -FilePath '.github/instructions/test.instructions.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
301 $result | Should -Match 'instruction-frontmatter\.schema\.json'
302 }
303
304 It 'Returns prompt schema for prompt files' {
305 $result = Get-SchemaForFile -FilePath '.github/prompts/build.prompt.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
306 $result | Should -Match 'prompt-frontmatter\.schema\.json'
307 }
308
309 It 'Returns chatmode schema for chatmode files' {
310 $result = Get-SchemaForFile -FilePath '.github/chatmodes/helper.chatmode.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
311 $result | Should -Match 'chatmode-frontmatter\.schema\.json'
312 }
313
314 It 'Returns agent schema for agent files' {
315 $result = Get-SchemaForFile -FilePath '.github/agents/worker.agent.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
316 $result | Should -Match 'agent-frontmatter\.schema\.json'
317 }
318
319 It 'Returns root-community schema for root community files' {
320 $result = Get-SchemaForFile -FilePath 'README.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
321 $result | Should -Match 'root-community-frontmatter\.schema\.json'
322 }
323
324 It 'Returns base schema for unknown file types' {
325 $result = Get-SchemaForFile -FilePath 'random/file.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
326 $result | Should -Match 'base-frontmatter\.schema\.json'
327 }
328 }
329
330 Context 'Pipe-separated pattern matching' {
331 It 'Matches root file from pipe-separated pattern' {
332 # Test CONTRIBUTING.md which should match the pipe-separated pattern in schema-mapping.json
333 $result = Get-SchemaForFile -FilePath 'CONTRIBUTING.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
334 $result | Should -Match 'root-community-frontmatter\.schema\.json'
335 }
336
337 It 'Matches CODE_OF_CONDUCT.md from pipe-separated pattern' {
338 $result = Get-SchemaForFile -FilePath 'CODE_OF_CONDUCT.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
339 $result | Should -Match 'root-community-frontmatter\.schema\.json'
340 }
341
342 It 'Matches SECURITY.md from pipe-separated pattern' {
343 $result = Get-SchemaForFile -FilePath 'SECURITY.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
344 $result | Should -Match 'root-community-frontmatter\.schema\.json'
345 }
346
347 It 'Falls back to base schema for unlisted root files' {
348 # LICENSE is not in the pipe-separated pattern, so should fall back to base
349 $result = Get-SchemaForFile -FilePath 'LICENSE' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
350 $result | Should -Match 'base-frontmatter\.schema\.json'
351 }
352 }
353
354 Context 'Simple glob pattern matching' {
355 It 'Matches skill file using simple glob pattern' {
356 $result = Get-SchemaForFile -FilePath '.github/skills/test-skill/SKILL.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
357 $result | Should -Match 'skill-frontmatter\.schema\.json'
358 }
359
360 It 'Falls back to base schema for paths not matching any pattern' {
361 # A path that doesn't match any defined patterns
362 $result = Get-SchemaForFile -FilePath 'misc/random/file.md' -SchemaDirectory $script:SchemaDir -RepoRoot $script:RepoRoot
363 $result | Should -Match 'base-frontmatter\.schema\.json'
364 }
365 }
366
367 Context 'Auto RepoRoot resolution' {
368 It 'Auto-detects repo root when RepoRoot is not specified' {
369 $result = Get-SchemaForFile -FilePath 'docs/guide/readme.md' -SchemaDirectory $script:SchemaDir
370 $result | Should -Match 'docs-frontmatter\.schema\.json'
371 }
372
373 It 'Returns null when no .git directory is found' {
374 $isolatedDir = Join-Path $TestDrive 'isolated-schemas'
375 New-Item -ItemType Directory -Path $isolatedDir -Force | Out-Null
376 '{"mappings": [], "defaultSchema": "base.schema.json"}' | Set-Content -Path (Join-Path $isolatedDir 'schema-mapping.json')
377
378 Mock Test-Path { return $false } -ParameterFilter { $Path -like '*\.git' -or $Path -like '*/.git' }
379
380 $result = Get-SchemaForFile -FilePath 'test.md' -SchemaDirectory $isolatedDir 3>$null
381 $result | Should -BeNullOrEmpty
382 }
383 }
384}
385
386#endregion
387
388#region Test-JsonSchemaValidation Tests
389
390Describe 'Test-JsonSchemaValidation' -Tag 'Unit' {
391 BeforeAll {
392 $script:DocsSchemaPath = Join-Path $script:SchemaDir 'docs-frontmatter.schema.json'
393 $script:DocsSchema = Get-Content -Path $script:DocsSchemaPath -Raw | ConvertFrom-Json
394 $script:BaseSchemaPath = Join-Path $script:SchemaDir 'base-frontmatter.schema.json'
395 $script:BaseSchema = Get-Content -Path $script:BaseSchemaPath -Raw | ConvertFrom-Json
396 }
397
398 Context 'Required fields validation' {
399 It 'Fails when required field is missing' {
400 $frontmatter = @{ title = 'Test' }
401 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:DocsSchema
402 $result.GetType().Name | Should -Be 'SchemaValidationResult'
403 $result.IsValid | Should -BeFalse
404 }
405
406 It 'Passes with all required fields' {
407 $frontmatter = @{
408 title = 'Test'
409 description = 'Valid description'
410 }
411 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:DocsSchema
412 $result.IsValid | Should -BeTrue
413 }
414 }
415
416 Context 'Pattern validation' {
417 BeforeAll {
418 # Create inline schema since $ref is not resolved by Test-JsonSchemaValidation
419 $script:PatternTestSchema = @{
420 required = @('title', 'description')
421 properties = @{
422 title = @{ type = 'string'; minLength = 1 }
423 description = @{ type = 'string'; minLength = 1 }
424 'ms.date' = @{ type = 'string'; pattern = '^\d{4}-\d{2}-\d{2}$' }
425 }
426 } | ConvertTo-Json -Depth 10 | ConvertFrom-Json
427 }
428
429 It 'Fails for invalid date format' {
430 $frontmatter = @{
431 title = 'Test'
432 description = 'Valid'
433 'ms.date' = '2025/01/16'
434 }
435 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:PatternTestSchema
436 $result.IsValid | Should -BeFalse
437 }
438
439 It 'Passes for valid date format' {
440 $frontmatter = @{
441 title = 'Test'
442 description = 'Valid'
443 'ms.date' = '2025-01-16'
444 }
445 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:PatternTestSchema
446 $result.IsValid | Should -BeTrue
447 }
448 }
449
450 Context 'Enum validation' {
451 It 'Fails for invalid ms.topic value' {
452 $frontmatter = @{
453 title = 'Test'
454 description = 'Valid'
455 'ms.topic' = 'invalid-topic-type'
456 }
457 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:DocsSchema
458 $result.IsValid | Should -BeFalse
459 }
460
461 It 'Passes for valid ms.topic value' {
462 $frontmatter = @{
463 title = 'Test'
464 description = 'Valid'
465 'ms.topic' = 'overview'
466 }
467 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:DocsSchema
468 $result.IsValid | Should -BeTrue
469 }
470 }
471
472 Context 'Return type structure' {
473 It 'Returns SchemaValidationResult with expected properties' {
474 $frontmatter = @{ description = 'Test' }
475 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:BaseSchema
476 $result.PSObject.Properties.Name | Should -Contain 'IsValid'
477 $result.PSObject.Properties.Name | Should -Contain 'Errors'
478 $result.PSObject.Properties.Name | Should -Contain 'Warnings'
479 $result.PSObject.Properties.Name | Should -Contain 'SchemaUsed'
480 }
481 }
482
483 Context 'Array type validation' {
484 BeforeAll {
485 $script:ArrayTestSchema = @{
486 required = @('description')
487 properties = @{
488 description = @{ type = 'string'; minLength = 1 }
489 applyTo = @{ type = 'array'; items = @{ type = 'string' } }
490 }
491 } | ConvertTo-Json -Depth 10 | ConvertFrom-Json
492 }
493
494 It 'Validates array field with empty array' {
495 $frontmatter = @{
496 description = 'test'
497 applyTo = @()
498 }
499 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:ArrayTestSchema
500 $result.Errors | Where-Object { $_ -like '*applyTo*' } | Should -BeNullOrEmpty
501 }
502
503 It 'Validates array field with valid string items' {
504 $frontmatter = @{
505 description = 'test'
506 applyTo = @('*.md', '*.txt')
507 }
508 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:ArrayTestSchema
509 $result.Errors | Where-Object { $_ -like '*applyTo*' } | Should -BeNullOrEmpty
510 }
511
512 It 'Reports error when string value used for array field' {
513 # Strings implement IEnumerable but should not pass array validation
514 $frontmatter = @{
515 description = 'test'
516 applyTo = 'single-value'
517 }
518 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:ArrayTestSchema
519 $result.IsValid | Should -BeFalse
520 $result.Errors | Should -Contain "Field 'applyTo' must be an array"
521 }
522
523 It 'Reports error when array field has numeric value' {
524 $frontmatter = @{
525 description = 'test'
526 applyTo = 123
527 }
528 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:ArrayTestSchema
529 $result.IsValid | Should -BeFalse
530 $result.Errors | Should -Contain "Field 'applyTo' must be an array"
531 }
532 }
533
534 Context 'Boolean type validation' {
535 BeforeAll {
536 $script:BoolTestSchema = @{
537 required = @('description')
538 properties = @{
539 description = @{ type = 'string'; minLength = 1 }
540 deprecated = @{ type = 'boolean' }
541 }
542 } | ConvertTo-Json -Depth 10 | ConvertFrom-Json
543 }
544
545 It 'Accepts valid boolean true value' {
546 $frontmatter = @{
547 description = 'test'
548 deprecated = $true
549 }
550 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:BoolTestSchema
551 $result.Errors | Where-Object { $_ -like '*deprecated*' } | Should -BeNullOrEmpty
552 }
553
554 It 'Accepts valid boolean false value' {
555 $frontmatter = @{
556 description = 'test'
557 deprecated = $false
558 }
559 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:BoolTestSchema
560 $result.Errors | Where-Object { $_ -like '*deprecated*' } | Should -BeNullOrEmpty
561 }
562
563 It 'Accepts string true/false as boolean' {
564 $frontmatter = @{
565 description = 'test'
566 deprecated = 'true'
567 }
568 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:BoolTestSchema
569 $result.Errors | Where-Object { $_ -like '*deprecated*' } | Should -BeNullOrEmpty
570 }
571
572 It 'Reports error when boolean field has invalid string value' {
573 $frontmatter = @{
574 description = 'test'
575 deprecated = 'yes'
576 }
577 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:BoolTestSchema
578 $result.IsValid | Should -BeFalse
579 $result.Errors | Should -Contain "Field 'deprecated' must be a boolean"
580 }
581
582 It 'Reports error when boolean field has numeric value' {
583 $frontmatter = @{
584 description = 'test'
585 deprecated = 1
586 }
587 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:BoolTestSchema
588 $result.IsValid | Should -BeFalse
589 $result.Errors | Should -Contain "Field 'deprecated' must be a boolean"
590 }
591 }
592
593 Context 'Enum validation with arrays' {
594 BeforeAll {
595 $script:EnumArraySchema = @{
596 required = @('description')
597 properties = @{
598 description = @{ type = 'string'; minLength = 1 }
599 tags = @{
600 type = 'array'
601 items = @{ type = 'string' }
602 enum = @('stable', 'preview', 'deprecated')
603 }
604 }
605 } | ConvertTo-Json -Depth 10 | ConvertFrom-Json
606 }
607
608 It 'Passes when array contains only valid enum values' {
609 $frontmatter = @{
610 description = 'test'
611 tags = @('stable', 'preview')
612 }
613 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:EnumArraySchema
614 $result.Errors | Where-Object { $_ -like '*tags*' } | Should -BeNullOrEmpty
615 }
616
617 It 'Reports error when array contains invalid enum value' {
618 $frontmatter = @{
619 description = 'test'
620 tags = @('stable', 'invalid-value')
621 }
622 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:EnumArraySchema
623 $result.IsValid | Should -BeFalse
624 $result.Errors | Where-Object { $_ -like '*invalid-value*' } | Should -Not -BeNullOrEmpty
625 }
626 }
627
628 Context 'MinLength validation' {
629 BeforeAll {
630 $script:MinLengthSchema = @{
631 required = @('description')
632 properties = @{
633 description = @{ type = 'string'; minLength = 10 }
634 title = @{ type = 'string'; minLength = 5 }
635 }
636 } | ConvertTo-Json -Depth 10 | ConvertFrom-Json
637 }
638
639 It 'Passes when string meets minimum length requirement' {
640 $frontmatter = @{
641 description = 'This is a sufficiently long description'
642 title = 'Valid Title'
643 }
644 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:MinLengthSchema
645 $result.IsValid | Should -BeTrue
646 }
647
648 It 'Reports error when string is shorter than minLength' {
649 $frontmatter = @{
650 description = 'Short'
651 }
652 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:MinLengthSchema
653 $result.IsValid | Should -BeFalse
654 $result.Errors | Should -Contain "Field 'description' must have minimum length of 10"
655 }
656
657 It 'Reports error for empty string when minLength is set' {
658 $frontmatter = @{
659 description = ''
660 }
661 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaContent $script:MinLengthSchema
662 $result.IsValid | Should -BeFalse
663 $result.Errors | Where-Object { $_ -like '*description*' -and $_ -like '*length*' } | Should -Not -BeNullOrEmpty
664 }
665 }
666}
667
668#endregion
669
670#region Get-ChangedMarkdownFileGroup Tests
671
672Describe 'Get-ChangedMarkdownFileGroup' -Tag 'Unit' {
673 BeforeAll {
674 Save-CIEnvironment
675 }
676
677 AfterAll {
678 Restore-CIEnvironment
679 }
680
681 Context 'Merge-base succeeds' {
682 BeforeEach {
683 Mock git {
684 $global:LASTEXITCODE = 0
685 return 'abc123def456789'
686 } -ParameterFilter { $args[0] -eq 'merge-base' }
687
688 Mock git {
689 $global:LASTEXITCODE = 0
690 return @('docs/test.md', 'README.md', 'scripts/README.md')
691 } -ParameterFilter { $args[0] -eq 'diff' }
692
693 Mock Test-Path { return $true } -ParameterFilter { $PathType -eq 'Leaf' }
694 }
695
696 It 'Returns changed markdown files' {
697 $result = Get-ChangedMarkdownFileGroup
698 $result | Should -BeOfType [string]
699 $result | Should -Contain 'docs/test.md'
700 $result | Should -Contain 'README.md'
701 }
702
703 It 'Filters to markdown files only' {
704 Mock git {
705 $global:LASTEXITCODE = 0
706 return @('test.md', 'test.ps1', 'test.json')
707 } -ParameterFilter { $args[0] -eq 'diff' }
708
709 $result = Get-ChangedMarkdownFileGroup
710 $result | Should -Contain 'test.md'
711 $result | Should -Not -Contain 'test.ps1'
712 $result | Should -Not -Contain 'test.json'
713 }
714
715 It 'Returns array of strings' {
716 $result = Get-ChangedMarkdownFileGroup
717 $result.Count | Should -BeGreaterOrEqual 0
718 }
719 }
720
721 Context 'Fallback scenarios' {
722 BeforeEach {
723 Mock git {
724 $global:LASTEXITCODE = 128
725 return $null
726 } -ParameterFilter { $args[0] -eq 'merge-base' }
727
728 Mock git {
729 $global:LASTEXITCODE = 0
730 return 'HEAD~1-sha'
731 } -ParameterFilter { $args[0] -eq 'rev-parse' }
732
733 Mock git {
734 $global:LASTEXITCODE = 0
735 return @('fallback.md')
736 } -ParameterFilter { $args[0] -eq 'diff' }
737
738 Mock Test-Path { return $true } -ParameterFilter { $PathType -eq 'Leaf' }
739 }
740
741 It 'Falls back to HEAD~1 when merge-base fails' {
742 $result = Get-ChangedMarkdownFileGroup
743 $result | Should -Contain 'fallback.md'
744 }
745
746 It 'Returns files when fallback succeeds' {
747 $result = Get-ChangedMarkdownFileGroup
748 $result.Count | Should -BeGreaterOrEqual 1
749 }
750 }
751
752 Context 'No changes detected' {
753 BeforeEach {
754 Mock git {
755 $global:LASTEXITCODE = 0
756 return 'abc123'
757 } -ParameterFilter { $args[0] -eq 'merge-base' }
758
759 Mock git {
760 $global:LASTEXITCODE = 0
761 return @()
762 } -ParameterFilter { $args[0] -eq 'diff' }
763 }
764
765 It 'Returns empty array when no changes' {
766 $result = Get-ChangedMarkdownFileGroup
767 $result.Count | Should -Be 0
768 }
769 }
770}
771
772#endregion
773
774#region Test-FrontmatterValidation Integration Tests
775
776Describe 'Test-FrontmatterValidation' -Tag 'Integration' {
777 BeforeAll {
778 Save-CIEnvironment
779 $script:TestRepoRoot = Join-Path $TestDrive 'test-repo'
780 }
781
782 BeforeEach {
783 New-Item -Path "$script:TestRepoRoot/docs" -ItemType Directory -Force | Out-Null
784 New-Item -Path "$script:TestRepoRoot/.github/instructions" -ItemType Directory -Force | Out-Null
785 New-Item -Path "$script:TestRepoRoot/scripts/linting/schemas" -ItemType Directory -Force | Out-Null
786
787 Copy-Item -Path "$script:SchemaDir/*" -Destination "$script:TestRepoRoot/scripts/linting/schemas/" -Force
788
789 $schemaMappingSource = Join-Path $script:SchemaDir 'schema-mapping.json'
790 if (Test-Path $schemaMappingSource) {
791 Copy-Item -Path $schemaMappingSource -Destination "$script:TestRepoRoot/scripts/linting/schemas/schema-mapping.json" -Force
792 }
793
794 # Change to test repo root so function detects it as repo root
795 Push-Location $script:TestRepoRoot
796 # Initialize minimal git repo for function's repo root detection
797 git init --quiet
798 }
799
800 AfterEach {
801 Pop-Location
802 }
803
804 AfterAll {
805 Restore-CIEnvironment
806 }
807
808 Context 'Valid files pass validation' {
809 BeforeEach {
810 @"
811---
812title: Test Documentation
813description: Valid documentation file
814ms.date: 2025-01-16
815ms.topic: overview
816---
817
818# Test
819
820Content here.
821"@ | Set-Content -Path "$script:TestRepoRoot/docs/test.md" -Encoding UTF8
822 }
823
824 It 'Returns ValidationSummary type' {
825 $result = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/test.md")
826 $result.GetType().Name | Should -Be 'ValidationSummary'
827 }
828
829 It 'Reports no errors for valid frontmatter' {
830 $result = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/test.md")
831 $result.GetExitCode($false) | Should -Be 0
832 $result.TotalErrors | Should -Be 0
833 }
834 }
835
836 Context 'Missing frontmatter fails' {
837 BeforeEach {
838 @"
839# No Frontmatter
840
841Just content without any YAML.
842"@ | Set-Content -Path "$script:TestRepoRoot/docs/no-frontmatter.md" -Encoding UTF8
843 }
844
845 It 'Reports warning for missing frontmatter' {
846 $result = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/no-frontmatter.md")
847 # Missing frontmatter in docs is a warning, not an error
848 $result.TotalWarnings | Should -BeGreaterThan 0
849 $warningMessages = $result.Results | ForEach-Object { $_.Issues | Where-Object Type -eq 'Warning' } | ForEach-Object { $_.Message }
850 $warningMessages | Where-Object { $_ -match 'No frontmatter found' } | Should -Not -BeNullOrEmpty
851 }
852 }
853
854 Context 'Empty description fails' {
855 BeforeEach {
856 @"
857---
858title: Has Title
859description: ""
860---
861
862Content
863"@ | Set-Content -Path "$script:TestRepoRoot/docs/empty-desc.md" -Encoding UTF8
864 }
865
866 It 'Reports error for empty description' {
867 # Missing required description field is a validation error
868 $result = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/empty-desc.md")
869 # Empty required field causes validation error
870 $result.TotalErrors | Should -BeGreaterThan 0
871 }
872 }
873
874 Context 'Invalid date format fails' {
875 BeforeEach {
876 # docs-frontmatter.schema.json requires BOTH title AND description
877 @"
878---
879title: Bad Date File
880description: Valid description
881ms.date: 2025/01/16
882---
883
884Content
885"@ | Set-Content -Path "$script:TestRepoRoot/docs/bad-date.md" -Encoding UTF8
886 }
887
888 It 'Reports warning for invalid date format' {
889 # Invalid date format is a warning, not an error
890 $result = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/bad-date.md")
891 $result.GetExitCode($false) | Should -Be 0
892 $warningMessages = $result.Results | ForEach-Object { $_.Issues | Where-Object Type -eq 'Warning' } | ForEach-Object { $_.Message }
893 ($warningMessages -join "`n") | Should -Match 'Invalid date format'
894 }
895 }
896
897 Context 'Multiple file validation' {
898 BeforeEach {
899 # docs-frontmatter.schema.json requires BOTH title AND description
900 @"
901---
902title: Valid File 1
903description: Valid file 1
904---
905Content
906"@ | Set-Content -Path "$script:TestRepoRoot/docs/valid1.md" -Encoding UTF8
907
908 @"
909---
910title: Valid File 2
911description: Valid file 2
912---
913Content
914"@ | Set-Content -Path "$script:TestRepoRoot/docs/valid2.md" -Encoding UTF8
915 }
916
917 It 'Validates multiple files in directory' {
918 $result = Test-FrontmatterValidation -Paths @("$script:TestRepoRoot/docs")
919 $result.TotalFiles | Should -BeGreaterOrEqual 2
920 }
921
922 It 'Uses Paths parameter when Files is not provided' {
923 # Test the else branch in main execution that uses Paths
924 $result = Test-FrontmatterValidation -Paths @("$script:TestRepoRoot/docs")
925 $result | Should -Not -BeNullOrEmpty
926 $result.TotalFiles | Should -BeGreaterThan 0
927 }
928 }
929
930 Context 'Result aggregation' {
931 It 'Aggregates results in ValidationSummary' {
932 # docs-frontmatter.schema.json requires BOTH title AND description
933 @"
934---
935title: Test File
936description: Valid
937---
938Content
939"@ | Set-Content -Path "$script:TestRepoRoot/docs/test.md" -Encoding UTF8
940
941 $result = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/test.md")
942 $result.PSObject.Properties.Name | Should -Contain 'Results'
943 $result.PSObject.Properties.Name | Should -Contain 'TotalFiles'
944 $result.PSObject.Properties.Name | Should -Contain 'FilesWithErrors'
945 $result.PSObject.Properties.Name | Should -Contain 'FilesWithWarnings'
946 }
947 }
948
949 Context 'ChangedFilesOnly mode' {
950 BeforeEach {
951 # Create valid test file
952 @"
953---
954title: Changed File
955description: A file detected as changed by git
956---
957Content
958"@ | Set-Content -Path "$script:TestRepoRoot/docs/changed.md" -Encoding UTF8
959 }
960
961 It 'Returns success ValidationSummary when no changed files found' {
962 # Mock Get-ChangedMarkdownFileGroup to return empty
963 Mock Get-ChangedMarkdownFileGroup { return @() }
964
965 $result = Test-FrontmatterValidation -ChangedFilesOnly
966
967 # TotalFiles=0 accurately represents no files were validated
968 # This is a successful no-op, not a validation failure
969 $result.TotalFiles | Should -Be 0
970 $result.FilesValid | Should -Be 0
971 # Verify the summary was completed
972 $result.Duration | Should -Not -BeNullOrEmpty
973 }
974
975 It 'Validates only files returned by Get-ChangedMarkdownFileGroup' {
976 # Mock Get-ChangedMarkdownFileGroup to return specific file
977 Mock Get-ChangedMarkdownFileGroup {
978 return @("$script:TestRepoRoot/docs/changed.md")
979 }
980
981 $result = Test-FrontmatterValidation -ChangedFilesOnly
982
983 $result.TotalFiles | Should -Be 1
984 }
985
986 It 'Passes BaseBranch parameter to Get-ChangedMarkdownFileGroup' {
987 Mock Get-ChangedMarkdownFileGroup {
988 return @()
989 } -ParameterFilter { $BaseBranch -eq 'develop' }
990
991 $null = Test-FrontmatterValidation -ChangedFilesOnly -BaseBranch 'develop'
992
993 Should -Invoke Get-ChangedMarkdownFileGroup -ParameterFilter { $BaseBranch -eq 'develop' }
994 }
995 }
996
997 Context 'EnableSchemaValidation mode' {
998 BeforeEach {
999 @"
1000---
1001title: Schema Test Doc
1002description: Valid test document for schema overlay
1003---
1004
1005# Test Content
1006"@ | Set-Content -Path "$script:TestRepoRoot/docs/schema-test.md" -Encoding UTF8
1007 }
1008
1009 It 'Invokes schema validation on files with frontmatter' {
1010 Mock Initialize-JsonSchemaValidation { return $true }
1011 Mock Get-SchemaForFile { return (Join-Path $script:SchemaDir 'docs-frontmatter.schema.json') }
1012 Mock Test-JsonSchemaValidation {
1013 return [PSCustomObject]@{ IsValid = $true; Errors = @(); Warnings = @() }
1014 }
1015
1016 $null = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/schema-test.md") -EnableSchemaValidation -SkipFooterValidation
1017
1018 Should -Invoke Get-SchemaForFile -Times 1
1019 Should -Invoke Test-JsonSchemaValidation -Times 1
1020 }
1021
1022 It 'Writes warnings when schema validation reports errors' {
1023 Mock Initialize-JsonSchemaValidation { return $true }
1024 Mock Get-SchemaForFile { return (Join-Path $script:SchemaDir 'docs-frontmatter.schema.json') }
1025 Mock Test-JsonSchemaValidation {
1026 return [PSCustomObject]@{ IsValid = $false; Errors = @('Missing required field: ms.date'); Warnings = @() }
1027 }
1028
1029 $null = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/schema-test.md") -EnableSchemaValidation -SkipFooterValidation -WarningVariable warnings 3>$null
1030
1031 $schemaWarnings = $warnings | Where-Object { $_ -match 'JSON Schema validation errors' -or $_ -match 'ms\.date' }
1032 $schemaWarnings | Should -Not -BeNullOrEmpty
1033 }
1034
1035 It 'Skips schema check when file has no frontmatter' {
1036 @"
1037# No Frontmatter
1038
1039Just content without YAML.
1040"@ | Set-Content -Path "$script:TestRepoRoot/docs/no-fm-schema.md" -Encoding UTF8
1041
1042 Mock Initialize-JsonSchemaValidation { return $true }
1043 Mock Get-SchemaForFile {}
1044 Mock Test-JsonSchemaValidation {}
1045
1046 $null = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/no-fm-schema.md") -EnableSchemaValidation -SkipFooterValidation
1047
1048 Should -Invoke Get-SchemaForFile -Times 0
1049 }
1050
1051 It 'Skips Test-JsonSchemaValidation when no schema matches file' {
1052 Mock Initialize-JsonSchemaValidation { return $true }
1053 Mock Get-SchemaForFile { return $null }
1054 Mock Test-JsonSchemaValidation {}
1055
1056 $null = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/schema-test.md") -EnableSchemaValidation -SkipFooterValidation
1057
1058 Should -Invoke Get-SchemaForFile -Times 1
1059 Should -Invoke Test-JsonSchemaValidation -Times 0
1060 }
1061
1062 It 'Skips overlay entirely when Initialize-JsonSchemaValidation returns false' {
1063 Mock Initialize-JsonSchemaValidation { return $false }
1064 Mock Get-SchemaForFile {}
1065
1066 $null = Test-FrontmatterValidation -Files @("$script:TestRepoRoot/docs/schema-test.md") -EnableSchemaValidation -SkipFooterValidation
1067
1068 Should -Invoke Get-SchemaForFile -Times 0
1069 }
1070 }
1071}
1072
1073#endregion
1074
1075#region ExcludePaths Filtering Tests
1076
1077Describe 'ExcludePaths Filtering' -Tag 'Unit' {
1078 BeforeAll {
1079 # Create test directory structure with files to include and exclude
1080 $script:ExcludeTestDir = Join-Path $TestDrive 'exclude-test'
1081 New-Item -ItemType Directory -Path "$script:ExcludeTestDir/docs" -Force | Out-Null
1082 New-Item -ItemType Directory -Path "$script:ExcludeTestDir/tests/fixtures" -Force | Out-Null
1083
1084 # Valid file that should be included
1085 @"
1086---
1087title: Include This
1088description: File that should be validated
1089---
1090Content
1091"@ | Set-Content -Path "$script:ExcludeTestDir/docs/include.md" -Encoding UTF8
1092
1093 # File in tests directory that should be excluded
1094 @"
1095---
1096title: Exclude This
1097description: File in tests folder
1098---
1099Content
1100"@ | Set-Content -Path "$script:ExcludeTestDir/tests/fixtures/exclude.md" -Encoding UTF8
1101 }
1102
1103 Context 'Excludes files matching single pattern' {
1104 It 'Excludes files matching pattern with wildcard prefix' {
1105 # Use wildcard prefix since ExcludePaths computes relative path from repo root
1106 # For files outside repo, the full path is used, so we match with *tests*
1107 $result = Test-FrontmatterValidation -Paths @($script:ExcludeTestDir) -ExcludePaths @('*tests*')
1108 # Should only check docs/include.md, not tests/fixtures/exclude.md
1109 $result.TotalFiles | Should -Be 1
1110 }
1111 }
1112
1113 Context 'Excludes files matching multiple patterns' {
1114 BeforeAll {
1115 # Add another directory to exclude
1116 New-Item -ItemType Directory -Path "$script:ExcludeTestDir/vendor" -Force | Out-Null
1117 @"
1118---
1119title: Vendor File
1120description: Third party content
1121---
1122Content
1123"@ | Set-Content -Path "$script:ExcludeTestDir/vendor/third-party.md" -Encoding UTF8
1124 }
1125
1126 It 'Excludes files matching multiple patterns' {
1127 $result = Test-FrontmatterValidation -Paths @($script:ExcludeTestDir) -ExcludePaths @('*tests*', '*vendor*')
1128 # Should only check docs/include.md
1129 $result.TotalFiles | Should -Be 1
1130 }
1131 }
1132
1133 Context 'Processes all files when ExcludePaths is empty' {
1134 It 'Validates all markdown files without exclusions' {
1135 $result = Test-FrontmatterValidation -Paths @($script:ExcludeTestDir) -ExcludePaths @()
1136 # Should check all markdown files (docs + tests + vendor)
1137 $result.TotalFiles | Should -BeGreaterOrEqual 2
1138 }
1139 }
1140
1141 Context 'Pattern matching behavior' {
1142 It 'Matches glob pattern with double asterisk for relative paths' {
1143 $relativePath = 'tests/fixtures/exclude.md'
1144 $pattern = 'tests/**'
1145 $relativePath -like $pattern | Should -BeTrue
1146 }
1147
1148 It 'Does not match non-matching patterns' {
1149 $relativePath = 'docs/include.md'
1150 $pattern = 'tests/**'
1151 $relativePath -like $pattern | Should -BeFalse
1152 }
1153
1154 It 'Matches pattern with single asterisk for file names' {
1155 $relativePath = 'docs/README.md'
1156 $pattern = 'docs/*.md'
1157 $relativePath -like $pattern | Should -BeTrue
1158 }
1159 }
1160
1161 Context 'FooterExcludePaths integration' {
1162 It 'Passes FooterExcludePaths to Invoke-FrontmatterValidation' {
1163 $testFile = Join-Path $TestDrive 'CHANGELOG.md'
1164 Set-Content $testFile "---`ndescription: Release history`n---`n# Changelog`n`nNo footer here"
1165
1166 # File should not have footer error when excluded (use wildcard to match filename in any path)
1167 $result = Test-FrontmatterValidation -Files @($testFile) -FooterExcludePaths @('*CHANGELOG.md')
1168 $footerErrors = $result.Results | ForEach-Object { $_.Issues } | Where-Object { $_.Field -eq 'footer' }
1169 $footerErrors | Should -BeNullOrEmpty
1170 }
1171
1172 It 'Applies footer validation to non-excluded files' {
1173 $testFile = Join-Path $TestDrive 'docs' 'guide.md'
1174 New-Item -ItemType Directory -Path (Join-Path $TestDrive 'docs') -Force | Out-Null
1175 Set-Content $testFile "---`ndescription: Test guide`n---`n# Guide`n`nNo footer here"
1176
1177 # Non-excluded file should have footer error
1178 $result = Test-FrontmatterValidation -Files @($testFile) -FooterExcludePaths @('*CHANGELOG.md')
1179 $footerErrors = $result.Results | ForEach-Object { $_.Issues } | Where-Object { $_.Field -eq 'footer' }
1180 $footerErrors | Should -Not -BeNullOrEmpty
1181 }
1182 }
1183}
1184
1185#endregion
1186
1187#region Error Handling Path Tests
1188
1189Describe 'Error handling paths' -Tag 'Unit' {
1190 Context 'Schema file error handling' {
1191 It 'Test-JsonSchemaValidation returns error for missing schema file' {
1192 $frontmatter = @{ title = 'Test'; description = 'Valid' }
1193 $missingSchemaPath = Join-Path $TestDrive 'does-not-exist.json'
1194 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaPath $missingSchemaPath
1195 $result.IsValid | Should -BeFalse
1196 $result.Errors | Should -Contain "Schema file not found: $missingSchemaPath"
1197 }
1198
1199 It 'Returns proper SchemaValidationResult on schema not found' {
1200 $frontmatter = @{ title = 'Test' }
1201 $missingSchemaPath = Join-Path $TestDrive 'missing-schema.json'
1202 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaPath $missingSchemaPath
1203 $result.GetType().Name | Should -Be 'SchemaValidationResult'
1204 $result.SchemaUsed | Should -Be $missingSchemaPath
1205 }
1206
1207 It 'Returns error for malformed JSON schema' {
1208 $badSchemaPath = Join-Path $TestDrive 'bad-schema.json'
1209 '{ invalid json }' | Set-Content -Path $badSchemaPath -Encoding UTF8
1210
1211 $frontmatter = @{ title = 'Test' }
1212 $result = Test-JsonSchemaValidation -Frontmatter $frontmatter -SchemaPath $badSchemaPath
1213 $result.IsValid | Should -BeFalse
1214 $result.Errors[0] | Should -Match 'Failed to parse schema'
1215 }
1216
1217 It 'Get-SchemaForFile returns null when mapping file is missing' {
1218 # Use platform-agnostic path for cross-platform compatibility
1219 $nonexistentPath = Join-Path $TestDrive 'nonexistent-schemas-dir'
1220 $result = Get-SchemaForFile -FilePath 'test.md' -SchemaDirectory $nonexistentPath
1221 $result | Should -BeNullOrEmpty
1222 }
1223
1224 It 'Get-SchemaForFile handles schema-mapping.json read errors gracefully' {
1225 $badMappingDir = Join-Path $TestDrive 'bad-mapping-dir'
1226 New-Item -ItemType Directory -Path $badMappingDir -Force | Out-Null
1227 '{ invalid json content }' | Set-Content -Path (Join-Path $badMappingDir 'schema-mapping.json') -Encoding UTF8
1228
1229 $null = Get-SchemaForFile -FilePath 'test.md' -SchemaDirectory $badMappingDir -WarningVariable warnings 3>$null
1230 $warnings | Should -Not -BeNullOrEmpty
1231 $warnings[0] | Should -Match 'Error reading schema mapping'
1232 }
1233 }
1234}
1235
1236Describe 'CI Environment Integration' -Tag 'Unit' {
1237 BeforeAll {
1238 . $PSScriptRoot/../../linting/Validate-MarkdownFrontmatter.ps1
1239 Import-Module $PSScriptRoot/../../linting/Modules/FrontmatterValidation.psm1 -Force
1240
1241 # Save original environment
1242 $script:OriginalGHA = $env:GITHUB_ACTIONS
1243 $script:OriginalStepSummary = $env:GITHUB_STEP_SUMMARY
1244 }
1245
1246 AfterAll {
1247 # Restore original environment
1248 $env:GITHUB_ACTIONS = $script:OriginalGHA
1249 $env:GITHUB_STEP_SUMMARY = $script:OriginalStepSummary
1250 }
1251
1252 Context 'Write-CIAnnotations execution path' {
1253 It 'Calls Write-CIAnnotations when CI is set' {
1254 $env:GITHUB_ACTIONS = 'true'
1255
1256 # Create test file with error
1257 $testFile = Join-Path $TestDrive 'ci-test.md'
1258 Set-Content $testFile "---`ndescription: x`n---`n# Test"
1259
1260 Mock Write-CIAnnotations { return '::error file=ci-test.md::' }
1261
1262 $null = Test-FrontmatterValidation -Files @($testFile) -SkipFooterValidation
1263
1264 # Annotation function should be called in CI environment
1265 Should -Invoke Write-CIAnnotations -Times 1 -Exactly
1266 }
1267 }
1268
1269 Context 'Step summary generation' {
1270 It 'Writes to step summary file when GITHUB_STEP_SUMMARY is set' {
1271 $env:GITHUB_ACTIONS = 'true'
1272 $stepSummaryPath = Join-Path $TestDrive 'step-summary.md'
1273 $env:GITHUB_STEP_SUMMARY = $stepSummaryPath
1274
1275 # Create valid test file
1276 $testFile = Join-Path $TestDrive 'valid-ci.md'
1277 Set-Content $testFile "---`ndescription: Valid test file`n---`n# Test"
1278
1279 $null = Test-FrontmatterValidation -Files @($testFile) -SkipFooterValidation
1280
1281 # Step summary should be written
1282 Test-Path $stepSummaryPath | Should -BeTrue
1283 }
1284
1285 It 'Writes fail step summary and sets FRONTMATTER_VALIDATION_FAILED env var' {
1286 Mock Set-CIEnv { }
1287
1288 $env:GITHUB_ACTIONS = 'true'
1289 $stepSummaryPath = Join-Path $TestDrive 'step-summary-fail.md'
1290 $env:GITHUB_STEP_SUMMARY = $stepSummaryPath
1291
1292 # File without frontmatter generates warning; -WarningsAsErrors makes GetExitCode non-zero
1293 $testFile = Join-Path $TestDrive 'fail-ci.md'
1294 Set-Content $testFile "# No Frontmatter`n`nContent without YAML front matter."
1295
1296 $null = Test-FrontmatterValidation -Files @($testFile) -WarningsAsErrors -SkipFooterValidation
1297
1298 Test-Path $stepSummaryPath | Should -BeTrue
1299 $content = Get-Content $stepSummaryPath -Raw
1300 $content | Should -Match 'Failed'
1301
1302 # Set-CIEnv writes to GITHUB_ENV file, not in-process env vars
1303 Should -Invoke Set-CIEnv -Times 1 -Exactly -ParameterFilter {
1304 $Name -eq 'FRONTMATTER_VALIDATION_FAILED' -and $Value -eq 'true'
1305 }
1306 }
1307 }
1308
1309 Context 'Main execution error handling with GitHub Actions' {
1310 It 'Outputs GitHub error annotation when validation throws exception in CI' {
1311 $env:GITHUB_ACTIONS = 'true'
1312
1313 # Create a file that will cause validation to fail
1314 $errorFile = Join-Path $TestDrive 'error-test.md'
1315 # Create malformed content
1316 Set-Content $errorFile "Malformed content"
1317
1318 # Mock a critical function to throw
1319 Mock Test-SingleFileFrontmatter { throw 'Validation critical error' }
1320
1321 # Act
1322 $output = Test-FrontmatterValidation -Files @($errorFile) 2>&1 3>&1
1323
1324 # Assert - Should attempt to output GitHub annotation on error
1325 # The error annotation is in the catch block
1326 $hasErrorOutput = $output | Where-Object { $_ -match 'error' }
1327 $hasErrorOutput | Should -Not -BeNullOrEmpty
1328 }
1329 }
1330}
1331
1332#endregion
1333
1334#region Git Fallback Strategy Tests
1335
1336Describe 'Git Fallback Strategies' -Tag 'Unit' {
1337 BeforeAll {
1338 . $PSScriptRoot/../../linting/Validate-MarkdownFrontmatter.ps1
1339 }
1340
1341 Context 'FallbackStrategy None behavior' {
1342 It 'Returns empty array when merge-base fails with FallbackStrategy None' {
1343 Mock git {
1344 $global:LASTEXITCODE = 128
1345 return $null
1346 }
1347
1348 $result = Get-ChangedMarkdownFileGroup -BaseBranch 'origin/main' -FallbackStrategy 'None'
1349 $result | Should -BeNullOrEmpty
1350 }
1351
1352 It 'Emits warning when merge-base fails with FallbackStrategy None' {
1353 Mock git {
1354 $global:LASTEXITCODE = 128
1355 return $null
1356 }
1357
1358 $null = Get-ChangedMarkdownFileGroup -FallbackStrategy 'None' -WarningVariable warnings 3>$null
1359 $warnings | Should -Not -BeNullOrEmpty
1360 $warnings[0] | Should -Match 'no fallback enabled'
1361 }
1362 }
1363
1364 Context 'FallbackStrategy HeadOnly behavior' {
1365 It 'Falls back to HEAD~1 when merge-base fails' {
1366 # The implementation uses $(git merge-base) inside git diff, so first call has 2 git invocations
1367 # Then fallback to HEAD~1 is another git diff call
1368 $callCount = 0
1369 Mock git {
1370 $callCount++
1371 # First two calls are merge-base + diff (which fails)
1372 if ($callCount -le 2) {
1373 $global:LASTEXITCODE = 128
1374 return $null
1375 }
1376 # Third call is HEAD~1 fallback
1377 $global:LASTEXITCODE = 0
1378 return @()
1379 }
1380
1381 $result = Get-ChangedMarkdownFileGroup -FallbackStrategy 'HeadOnly'
1382 $result | Should -BeNullOrEmpty
1383 # merge-base subexpression + diff + fallback HEAD~1 = 3 calls minimum
1384 Should -Invoke git -Times 3 -Exactly
1385 }
1386
1387 It 'Returns empty with warning when HEAD~1 also fails for HeadOnly' {
1388 Mock git {
1389 $global:LASTEXITCODE = 128
1390 return $null
1391 }
1392
1393 $null = Get-ChangedMarkdownFileGroup -FallbackStrategy 'HeadOnly' -WarningVariable warnings 3>$null
1394 $warnings | Should -Not -BeNullOrEmpty
1395 $warnings[0] | Should -Match 'Unable to determine changed files'
1396 }
1397
1398 It 'Emits verbose message when merge-base comparison fails' {
1399 Mock git {
1400 $global:LASTEXITCODE = 128
1401 return $null
1402 }
1403
1404 $output = Get-ChangedMarkdownFileGroup -FallbackStrategy 'HeadOnly' -Verbose 4>&1
1405 $verbose = $output | Where-Object { $_ -is [System.Management.Automation.VerboseRecord] }
1406 $messages = @($verbose | ForEach-Object { $_.Message })
1407 ($messages -match 'Merge base comparison.*failed').Count | Should -BeGreaterThan 0
1408 }
1409
1410 It 'Emits verbose message when attempting HEAD~1 fallback' {
1411 $callCount = 0
1412 Mock git {
1413 $callCount++
1414 if ($callCount -le 2) {
1415 $global:LASTEXITCODE = 128
1416 return $null
1417 }
1418 $global:LASTEXITCODE = 0
1419 return @('test.md')
1420 }
1421
1422 $output = Get-ChangedMarkdownFileGroup -FallbackStrategy 'HeadOnly' -Verbose 4>&1
1423 $verbose = $output | Where-Object { $_ -is [System.Management.Automation.VerboseRecord] }
1424 $messages = @($verbose | ForEach-Object { $_.Message })
1425 ($messages -match 'Attempting fallback.*HEAD~1').Count | Should -BeGreaterThan 0
1426 }
1427
1428 It 'Emits verbose count message when files found' {
1429 Mock git {
1430 $global:LASTEXITCODE = 0
1431 return @('docs/test.md', 'src/readme.md')
1432 }
1433
1434 $output = Get-ChangedMarkdownFileGroup -Verbose 4>&1
1435 $verbose = $output | Where-Object { $_ -is [System.Management.Automation.VerboseRecord] }
1436 $messages = @($verbose | ForEach-Object { $_.Message })
1437 ($messages -match 'Found.*changed markdown files').Count | Should -BeGreaterThan 0
1438 }
1439 }
1440
1441 Context 'FallbackStrategy Auto cascading behavior' {
1442 It 'Cascades through all fallback strategies when Auto' {
1443 # merge-base (1) + diff (2) fail, then HEAD~1 diff (3) fail, then HEAD diff (4) fail
1444 $callCount = 0
1445 Mock git {
1446 $callCount++
1447 if ($callCount -le 3) {
1448 $global:LASTEXITCODE = 128
1449 return $null
1450 }
1451 $global:LASTEXITCODE = 0
1452 return @()
1453 }
1454
1455 $null = Get-ChangedMarkdownFileGroup -FallbackStrategy 'Auto'
1456 # merge-base (1) + diff (2) + HEAD~1 fallback (3) + HEAD fallback (4) = 4 calls
1457 Should -Invoke git -Times 4 -Exactly
1458 }
1459
1460 It 'Returns empty with warning when all Auto fallbacks fail' {
1461 Mock git {
1462 $global:LASTEXITCODE = 128
1463 return $null
1464 }
1465
1466 $null = Get-ChangedMarkdownFileGroup -FallbackStrategy 'Auto' -WarningVariable warnings 3>$null
1467 $warnings | Should -Not -BeNullOrEmpty
1468 }
1469
1470 It 'Emits verbose message when HEAD~1 fails and falls back to staged' {
1471 $callCount = 0
1472 Mock git {
1473 $callCount++
1474 # merge-base (1) + diff (2) + HEAD~1 (3) all fail
1475 if ($callCount -le 3) {
1476 $global:LASTEXITCODE = 128
1477 return $null
1478 }
1479 # HEAD (staged/unstaged) succeeds
1480 $global:LASTEXITCODE = 0
1481 return @('staged.md')
1482 }
1483
1484 $output = Get-ChangedMarkdownFileGroup -FallbackStrategy 'Auto' -Verbose 4>&1
1485 $verbose = $output | Where-Object { $_ -is [System.Management.Automation.VerboseRecord] }
1486 $verboseMessages = $verbose.Message -join "`n"
1487 $verboseMessages | Should -Match 'staged|unstaged'
1488 }
1489
1490 It 'Succeeds when second fallback works' {
1491 $script:TestFilePath = Join-Path $TestDrive 'changed.md'
1492 @"
1493---
1494title: Test
1495description: Changed file
1496---
1497"@ | Set-Content -Path $script:TestFilePath -Encoding UTF8
1498
1499 $script:gitCallCount = 0
1500 Mock git {
1501 $script:gitCallCount++
1502 # First 2 calls (merge-base + diff) fail
1503 if ($script:gitCallCount -le 2) {
1504 $global:LASTEXITCODE = 128
1505 return $null
1506 }
1507 # Third call (HEAD~1 fallback) succeeds
1508 $global:LASTEXITCODE = 0
1509 return @($script:TestFilePath)
1510 }
1511
1512 $result = Get-ChangedMarkdownFileGroup -FallbackStrategy 'Auto'
1513 $result | Should -Contain $script:TestFilePath
1514 }
1515 }
1516
1517 Context 'Git exception handling' {
1518 It 'Returns empty array when git throws exception' {
1519 Mock git { throw 'fatal: not a git repository' }
1520
1521 $result = Get-ChangedMarkdownFileGroup
1522 $result | Should -BeNullOrEmpty
1523 }
1524
1525 It 'Emits warning with exception message when git fails' {
1526 Mock git { throw 'fatal: not a git repository' }
1527
1528 $null = Get-ChangedMarkdownFileGroup -WarningVariable warnings 3>$null
1529 $warnings | Should -Not -BeNullOrEmpty
1530 $warnings[0] | Should -Match 'Error getting changed files'
1531 }
1532 }
1533}
1534
1535#endregion
1536
1537#region Integration Modes Tests
1538
1539Describe 'Write-CIAnnotations' -Tag 'Unit' {
1540 BeforeAll {
1541 Import-Module (Join-Path $PSScriptRoot '../../linting/Modules/FrontmatterValidation.psm1') -Force
1542 }
1543
1544 Context 'GitHub Actions annotation output' {
1545 BeforeEach {
1546 $script:OriginalGHActions = $env:GITHUB_ACTIONS
1547 $env:GITHUB_ACTIONS = 'true'
1548 }
1549
1550 AfterEach {
1551 if ($null -eq $script:OriginalGHActions) {
1552 Remove-Item Env:GITHUB_ACTIONS -ErrorAction SilentlyContinue
1553 }
1554 else {
1555 $env:GITHUB_ACTIONS = $script:OriginalGHActions
1556 }
1557 }
1558
1559 It 'Outputs error annotation format for file errors' {
1560 # Arrange - Create summary with errors
1561 $summary = & (Get-Module FrontmatterValidation) { [ValidationSummary]::new() }
1562 $fileResult = & (Get-Module FrontmatterValidation) {
1563 $result = [FileValidationResult]::new('test/error.md')
1564 $result.AddError('Missing required field: description', 'description')
1565 $result
1566 }
1567 $summary.AddResult($fileResult)
1568
1569 # Act - Capture Write-Output
1570 $output = Write-CIAnnotations -Summary $summary
1571
1572 # Assert - Should output ::error:: annotation
1573 $output | Where-Object { $_ -like '::error*' } | Should -Not -BeNullOrEmpty
1574 }
1575
1576 It 'Outputs warning annotation format for file warnings' {
1577 # Arrange - Create summary with warnings only
1578 $summary = & (Get-Module FrontmatterValidation) { [ValidationSummary]::new() }
1579 $fileResult = & (Get-Module FrontmatterValidation) {
1580 $result = [FileValidationResult]::new('test/warning.md')
1581 $result.AddWarning('Suggested field missing: author', 'author')
1582 $result
1583 }
1584 $summary.AddResult($fileResult)
1585
1586 # Act - Capture Write-Output
1587 $output = Write-CIAnnotations -Summary $summary
1588
1589 # Assert - Should output ::warning:: annotation
1590 $output | Where-Object { $_ -like '::warning*' } | Should -Not -BeNullOrEmpty
1591 }
1592
1593 It 'Includes file path in annotations' {
1594 # Arrange
1595 $summary = & (Get-Module FrontmatterValidation) { [ValidationSummary]::new() }
1596 $fileResult = & (Get-Module FrontmatterValidation) {
1597 $result = [FileValidationResult]::new('docs/specific-file.md')
1598 $result.AddError('Test error', 'test')
1599 $result
1600 }
1601 $summary.AddResult($fileResult)
1602
1603 # Act - Capture Write-Output
1604 $output = Write-CIAnnotations -Summary $summary
1605
1606 # Assert - Annotation should include file path
1607 $output | Where-Object { $_ -like '*file=*specific-file*' } | Should -Not -BeNullOrEmpty
1608 }
1609 }
1610}
1611
1612Describe 'Empty Input Handling' -Tag 'Unit' {
1613 Context 'No files to validate' {
1614 It 'Warns when path contains no markdown files' {
1615 # Arrange
1616 $emptyDir = Join-Path $TestDrive 'empty-dir'
1617 New-Item -ItemType Directory -Path $emptyDir -Force | Out-Null
1618
1619 # Act & Assert - Test-FrontmatterValidation should handle empty gracefully
1620 $result = Test-FrontmatterValidation -Paths @($emptyDir) -WarningVariable warnings 3>$null
1621 $result.TotalFiles | Should -Be 0
1622 }
1623
1624 It 'Returns empty summary when all files are excluded' {
1625 # Arrange
1626 $excludeDir = Join-Path $TestDrive 'exclude-all'
1627 $nodeModules = Join-Path $excludeDir 'node_modules'
1628 New-Item -ItemType Directory -Path $nodeModules -Force | Out-Null
1629 Set-Content -Path (Join-Path $nodeModules 'readme.md') -Value "---`ndescription: excluded`n---"
1630
1631 # Act
1632 $result = Test-FrontmatterValidation -Paths @($excludeDir) -ExcludePaths @('**/node_modules/**')
1633
1634 # Assert
1635 $result.TotalFiles | Should -Be 0
1636 }
1637 }
1638}
1639
1640Describe 'ChangedFilesOnly Integration' -Tag 'Unit' {
1641 BeforeAll {
1642 $script:TestRoot = Join-Path $TestDrive 'changed-files-test'
1643 New-Item -ItemType Directory -Path $script:TestRoot -Force | Out-Null
1644 }
1645
1646 Context 'Git diff filtering' {
1647 It 'Returns only markdown files from git diff output' {
1648 # Arrange
1649 $mdFile = Join-Path $script:TestRoot 'readme.md'
1650 Set-Content -Path $mdFile -Value "---`ndescription: test`n---"
1651
1652 Mock git {
1653 $global:LASTEXITCODE = 0
1654 # Git returns multiple file types
1655 return @('readme.md', 'script.ps1', 'config.json')
1656 }
1657
1658 # Change to TestRoot so Test-Path resolves relative paths correctly
1659 Push-Location $script:TestRoot
1660 try {
1661 # Act
1662 $result = Get-ChangedMarkdownFileGroup -BaseBranch 'origin/main'
1663
1664 # Assert - Should filter to only .md files
1665 $result | Should -Contain 'readme.md'
1666 $result | Should -Not -Contain 'script.ps1'
1667 $result | Should -Not -Contain 'config.json'
1668 }
1669 finally {
1670 Pop-Location
1671 }
1672 }
1673
1674 It 'Returns empty array when git diff returns no files' {
1675 Mock git {
1676 $global:LASTEXITCODE = 0
1677 return @()
1678 }
1679
1680 # Act
1681 $result = Get-ChangedMarkdownFileGroup -BaseBranch 'origin/main'
1682
1683 # Assert
1684 $result | Should -BeNullOrEmpty
1685 }
1686
1687 It 'Handles mixed path separators in git output' {
1688 # Create test files that match git output paths
1689 $docsPath = Join-Path $TestDrive 'docs'
1690 $srcPath = Join-Path $TestDrive 'src' 'api'
1691 New-Item -Path $docsPath -ItemType Directory -Force | Out-Null
1692 New-Item -Path $srcPath -ItemType Directory -Force | Out-Null
1693 $file1 = Join-Path $docsPath 'readme.md'
1694 $file2 = Join-Path $srcPath 'guide.md'
1695 '---' + "`ntitle: Test`n---" | Set-Content -Path $file1 -Encoding UTF8
1696 '---' + "`ntitle: Test`n---" | Set-Content -Path $file2 -Encoding UTF8
1697
1698 Mock git {
1699 $global:LASTEXITCODE = 0
1700 return @($file1, $file2)
1701 }
1702
1703 # Act
1704 $result = Get-ChangedMarkdownFileGroup -BaseBranch 'origin/main'
1705
1706 # Assert - Should handle both path separators
1707 $result.Count | Should -Be 2
1708 }
1709 }
1710}
1711
1712#region Schema Pattern Matching Tests
1713
1714Describe 'Schema Pattern Matching' -Tag 'Unit' {
1715 BeforeAll {
1716 $script:MainScript = Join-Path $PSScriptRoot '../../linting/Validate-MarkdownFrontmatter.ps1'
1717 }
1718
1719 Context 'Pipe-separated and Array patterns' {
1720 It 'Validates pipe-separated patterns in applyTo' {
1721 # Arrange
1722 $testFile = Join-Path $TestDrive 'pipe-patterns.md'
1723 Set-Content -Path $testFile -Value @"
1724---
1725description: test
1726applyTo: "**/*.ts | **/*.tsx | **/*.js"
1727---
1728"@
1729
1730 # Act
1731 $result = Test-SingleFileFrontmatter -FilePath $testFile -RepoRoot $TestDrive
1732
1733 # Assert - Should accept pipe-separated patterns
1734 $result.Issues | Where-Object { $_.Field -eq 'applyTo' -and $_.Type -eq 'Error' } | Should -BeNullOrEmpty
1735 }
1736
1737 It 'Validates comma-separated patterns in applyTo array' {
1738 # Arrange
1739 $testFile = Join-Path $TestDrive 'array-patterns.md'
1740 Set-Content -Path $testFile -Value @"
1741---
1742description: test
1743applyTo:
1744 - "**/*.ts"
1745 - "**/*.tsx"
1746 - "**/components/**"
1747---
1748"@
1749
1750 # Act
1751 $result = Test-SingleFileFrontmatter -FilePath $testFile -RepoRoot $TestDrive
1752
1753 # Assert - Array format should be valid
1754 $result.Issues | Where-Object { $_.Field -eq 'applyTo' -and $_.Type -eq 'Error' } | Should -BeNullOrEmpty
1755 }
1756 }
1757
1758 Context 'Glob pattern validation' {
1759 It 'Validates double-star glob patterns' {
1760 # Arrange
1761 $testFile = Join-Path $TestDrive 'glob-doublestar.md'
1762 Set-Content -Path $testFile -Value @"
1763---
1764description: test
1765applyTo: "**/src/**/*.ts"
1766---
1767"@
1768
1769 # Act
1770 $result = Test-SingleFileFrontmatter -FilePath $testFile -RepoRoot $TestDrive
1771
1772 # Assert
1773 $result.Issues | Where-Object { $_.Field -eq 'applyTo' -and $_.Type -eq 'Error' } | Should -BeNullOrEmpty
1774 }
1775
1776 It 'Validates single-star glob patterns' {
1777 # Arrange
1778 $testFile = Join-Path $TestDrive 'glob-singlestar.md'
1779 Set-Content -Path $testFile -Value @"
1780---
1781description: test
1782applyTo: "src/*.ts"
1783---
1784"@
1785
1786 # Act
1787 $result = Test-SingleFileFrontmatter -FilePath $testFile -RepoRoot $TestDrive
1788
1789 # Assert
1790 $result.Issues | Where-Object { $_.Field -eq 'applyTo' -and $_.Type -eq 'Error' } | Should -BeNullOrEmpty
1791 }
1792
1793 It 'Validates question mark wildcard patterns' {
1794 # Arrange
1795 $testFile = Join-Path $TestDrive 'glob-question.md'
1796 Set-Content -Path $testFile -Value @"
1797---
1798description: test
1799applyTo: "src/file?.ts"
1800---
1801"@
1802
1803 # Act
1804 $result = Test-SingleFileFrontmatter -FilePath $testFile -RepoRoot $TestDrive
1805
1806 # Assert
1807 $result.Issues | Where-Object { $_.Field -eq 'applyTo' -and $_.Type -eq 'Error' } | Should -BeNullOrEmpty
1808 }
1809
1810 It 'Validates brace expansion patterns' {
1811 # Arrange
1812 $testFile = Join-Path $TestDrive 'glob-braces.md'
1813 Set-Content -Path $testFile -Value @"
1814---
1815description: test
1816applyTo: "**/*.{ts,tsx,js,jsx}"
1817---
1818"@
1819
1820 # Act
1821 $result = Test-SingleFileFrontmatter -FilePath $testFile -RepoRoot $TestDrive
1822
1823 # Assert
1824 $result.Issues | Where-Object { $_.Field -eq 'applyTo' -and $_.Type -eq 'Error' } | Should -BeNullOrEmpty
1825 }
1826 }
1827}
1828
1829#endregion
1830
1831#endregion
1832