microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/580-dt-start-project

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

1270lines · modecode

1# Copyright (c) Microsoft Corporation.
2# SPDX-License-Identifier: MIT
3
4#Requires -Modules Pester
5
6BeforeAll {
7 # Dot-source the main script
8 $scriptPath = Join-Path $PSScriptRoot '../../linting/Validate-SkillStructure.ps1'
9 . $scriptPath
10
11 $mockPath = Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1'
12 Import-Module $mockPath -Force
13
14 # Temp directory for test isolation
15 $script:TempTestDir = Join-Path ([System.IO.Path]::GetTempPath()) "SkillStructureTests_$([guid]::NewGuid().ToString('N'))"
16 New-Item -ItemType Directory -Path $script:TempTestDir -Force | Out-Null
17
18 function New-TestSkillDirectory {
19 param(
20 [string]$SkillName,
21 [string]$FrontmatterContent,
22 [switch]$NoSkillMd,
23 [switch]$WithScriptsDir,
24 [switch]$WithEmptyScriptsDir,
25 [switch]$WithUnrecognizedDir,
26 [string[]]$OptionalDirs = @()
27 )
28
29 $skillDir = Join-Path $script:TempTestDir $SkillName
30 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
31
32 if (-not $NoSkillMd) {
33 $skillMdPath = Join-Path $skillDir 'SKILL.md'
34 if ($FrontmatterContent) {
35 Set-Content -Path $skillMdPath -Value $FrontmatterContent
36 }
37 else {
38 Set-Content -Path $skillMdPath -Value '# Test Skill'
39 }
40 }
41
42 if ($WithScriptsDir) {
43 $scriptsDir = Join-Path $skillDir 'scripts'
44 New-Item -ItemType Directory -Path $scriptsDir -Force | Out-Null
45 Set-Content -Path (Join-Path $scriptsDir 'test.sh') -Value '#!/bin/bash'
46 }
47
48 if ($WithEmptyScriptsDir) {
49 New-Item -ItemType Directory -Path (Join-Path $skillDir 'scripts') -Force | Out-Null
50 }
51
52 if ($WithUnrecognizedDir) {
53 New-Item -ItemType Directory -Path (Join-Path $skillDir 'random-dir') -Force | Out-Null
54 }
55
56 foreach ($dir in $OptionalDirs) {
57 New-Item -ItemType Directory -Path (Join-Path $skillDir $dir) -Force | Out-Null
58 }
59
60 return Get-Item $skillDir
61 }
62}
63
64AfterAll {
65 if ($script:TempTestDir -and (Test-Path $script:TempTestDir)) {
66 Remove-Item -Path $script:TempTestDir -Recurse -Force -ErrorAction SilentlyContinue
67 }
68 Remove-Module CIHelpers -Force -ErrorAction SilentlyContinue
69 Remove-Module GitMocks -Force -ErrorAction SilentlyContinue
70 Remove-Module LintingHelpers -Force -ErrorAction SilentlyContinue
71}
72
73#region Get-SkillFrontmatter Tests
74
75Describe 'Get-SkillFrontmatter' -Tag 'Unit' {
76 Context 'Valid frontmatter' {
77 It 'Returns hashtable for valid frontmatter with name and description' {
78 $content = @"
79---
80name: test-skill
81description: A test skill for validation
82---
83
84# Test Skill
85"@
86 $filePath = Join-Path $script:TempTestDir 'valid-fm.md'
87 Set-Content -Path $filePath -Value $content
88
89 $result = Get-SkillFrontmatter -Path $filePath
90 $result | Should -Not -BeNullOrEmpty
91 $result | Should -BeOfType [hashtable]
92 $result['name'] | Should -BeExactly 'test-skill'
93 $result['description'] | Should -BeExactly 'A test skill for validation'
94 }
95
96 It 'Strips single-quoted values correctly' {
97 $content = @"
98---
99name: 'my-skill'
100description: 'A skill with single quotes - Brought to you by microsoft/hve-core'
101---
102
103# Skill
104"@
105 $filePath = Join-Path $script:TempTestDir 'single-quoted.md'
106 Set-Content -Path $filePath -Value $content
107
108 $result = Get-SkillFrontmatter -Path $filePath
109 $result | Should -Not -BeNullOrEmpty
110 $result['name'] | Should -BeExactly 'my-skill'
111 $result['description'] | Should -BeExactly 'A skill with single quotes - Brought to you by microsoft/hve-core'
112 }
113
114 It 'Strips double-quoted values correctly' {
115 $content = @"
116---
117name: "double-skill"
118description: "A skill with double quotes"
119---
120
121# Skill
122"@
123 $filePath = Join-Path $script:TempTestDir 'double-quoted.md'
124 Set-Content -Path $filePath -Value $content
125
126 $result = Get-SkillFrontmatter -Path $filePath
127 $result | Should -Not -BeNullOrEmpty
128 $result['name'] | Should -BeExactly 'double-skill'
129 $result['description'] | Should -BeExactly 'A skill with double quotes'
130 }
131
132 It 'Returns all fields including optional ones' {
133 $content = @"
134---
135name: advanced-skill
136description: An advanced skill
137user-invocable: true
138argument-hint: provide a URL
139---
140
141# Advanced Skill
142"@
143 $filePath = Join-Path $script:TempTestDir 'optional-fields.md'
144 Set-Content -Path $filePath -Value $content
145
146 $result = Get-SkillFrontmatter -Path $filePath
147 $result | Should -Not -BeNullOrEmpty
148 $result['name'] | Should -BeExactly 'advanced-skill'
149 $result['description'] | Should -BeExactly 'An advanced skill'
150 $result['user-invocable'] | Should -BeExactly 'true'
151 $result['argument-hint'] | Should -BeExactly 'provide a URL'
152 }
153
154 It 'Parses boolean values as strings (regex-based parser)' {
155 $content = @"
156---
157name: bool-skill
158description: Skill with booleans
159user-invocable: false
160---
161
162# Bool Skill
163"@
164 $filePath = Join-Path $script:TempTestDir 'bool-values.md'
165 Set-Content -Path $filePath -Value $content
166
167 $result = Get-SkillFrontmatter -Path $filePath
168 $result | Should -Not -BeNullOrEmpty
169 $result['user-invocable'] | Should -BeOfType [string]
170 $result['user-invocable'] | Should -BeExactly 'false'
171 }
172
173 }
174
175 Context 'Invalid or missing frontmatter' {
176 It 'Returns null for plain markdown without frontmatter' {
177 $content = @"
178# Just a Heading
179
180Some content without frontmatter.
181"@
182 $filePath = Join-Path $script:TempTestDir 'no-frontmatter.md'
183 Set-Content -Path $filePath -Value $content
184
185 $result = Get-SkillFrontmatter -Path $filePath
186 $result | Should -BeNullOrEmpty
187 }
188
189 It 'Returns null for malformed frontmatter (missing closing ---)' {
190 $content = @"
191---
192name: broken-skill
193description: Missing closing delimiter
194
195# Some content
196"@
197 $filePath = Join-Path $script:TempTestDir 'malformed-fm.md'
198 Set-Content -Path $filePath -Value $content
199
200 $result = Get-SkillFrontmatter -Path $filePath
201 $result | Should -BeNullOrEmpty
202 }
203
204 It 'Returns null for empty file' {
205 $filePath = Join-Path $script:TempTestDir 'empty.md'
206 Set-Content -Path $filePath -Value ''
207
208 $result = Get-SkillFrontmatter -Path $filePath
209 $result | Should -BeNullOrEmpty
210 }
211
212 It 'Returns null when file does not exist' {
213 $filePath = Join-Path $script:TempTestDir 'nonexistent-file.md'
214
215 $result = Get-SkillFrontmatter -Path $filePath
216 $result | Should -BeNullOrEmpty
217 }
218
219 It 'Returns null for frontmatter block with no valid key-value pairs' {
220 $content = @"
221---
222 just some random text
223 no key value pairs here
224---
225
226# Content
227"@
228 $filePath = Join-Path $script:TempTestDir 'no-kv-pairs.md'
229 Set-Content -Path $filePath -Value $content
230
231 $result = Get-SkillFrontmatter -Path $filePath
232 $result | Should -BeNullOrEmpty
233 }
234 }
235}
236
237#endregion
238
239#region Test-SkillDirectory Tests
240
241Describe 'Test-SkillDirectory' -Tag 'Unit' {
242 BeforeAll {
243 $script:SkillTestDir = Join-Path $script:TempTestDir 'skill-dir-tests'
244 New-Item -ItemType Directory -Path $script:SkillTestDir -Force | Out-Null
245
246 # Override TempTestDir for fixture helper within this Describe
247 $script:TempTestDir = $script:SkillTestDir
248 }
249
250 AfterAll {
251 $script:TempTestDir = (Split-Path $script:SkillTestDir -Parent)
252 }
253
254 Context 'Valid skill directory' {
255 It 'Passes validation with proper SKILL.md and matching name' {
256 $frontmatter = @"
257---
258name: test-skill
259description: 'A test skill for validation - Brought to you by microsoft/hve-core'
260---
261
262# Test Skill
263"@
264 $dir = New-TestSkillDirectory -SkillName 'test-skill' -FrontmatterContent $frontmatter
265
266 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
267 $result.IsValid | Should -BeTrue
268 $result.Errors | Should -HaveCount 0
269 $result.Warnings | Should -HaveCount 0
270 $result.SkillName | Should -BeExactly 'test-skill'
271 }
272
273 It 'Passes with valid optional directories and no warnings' {
274 $frontmatter = @"
275---
276name: dirs-skill
277description: 'Skill with optional dirs'
278---
279
280# Dirs Skill
281"@
282 $dir = New-TestSkillDirectory -SkillName 'dirs-skill' -FrontmatterContent $frontmatter -OptionalDirs @('scripts', 'references', 'assets', 'examples')
283 # Add both script types so scripts/ passes validation
284 Set-Content -Path (Join-Path $dir.FullName 'scripts/run.sh') -Value '#!/bin/bash'
285 Set-Content -Path (Join-Path $dir.FullName 'scripts/run.ps1') -Value 'Write-Host "hello"'
286
287 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
288 $result.IsValid | Should -BeTrue
289 $result.Warnings | Should -HaveCount 0
290 }
291 }
292
293 Context 'Missing SKILL.md' {
294 It 'Reports error when SKILL.md is missing' {
295 $dir = New-TestSkillDirectory -SkillName 'no-skillmd' -NoSkillMd
296
297 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
298 $result.IsValid | Should -BeFalse
299 $result.Errors | Should -HaveCount 1
300 $result.Errors[0] | Should -BeLike '*SKILL.md is missing*'
301 }
302 }
303
304 Context 'Frontmatter issues' {
305 It 'Reports error when SKILL.md has no frontmatter' {
306 $dir = New-TestSkillDirectory -SkillName 'no-fm-skill'
307 # Default content is just "# Test Skill" without frontmatter
308
309 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
310 $result.IsValid | Should -BeFalse
311 $result.Errors | Should -HaveCount 1
312 $result.Errors[0] | Should -BeLike '*missing or malformed frontmatter*'
313 }
314
315 It 'Reports error when frontmatter is missing name field' {
316 $frontmatter = @"
317---
318description: 'A skill without a name'
319---
320
321# No Name
322"@
323 $dir = New-TestSkillDirectory -SkillName 'missing-name' -FrontmatterContent $frontmatter
324
325 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
326 $result.IsValid | Should -BeFalse
327 $result.Errors | Should -Contain ($result.Errors | Where-Object { $_ -like "*missing required 'name'*" })
328 }
329
330 It 'Reports error when frontmatter is missing description field' {
331 $frontmatter = @"
332---
333name: missing-desc
334---
335
336# Missing Desc
337"@
338 $dir = New-TestSkillDirectory -SkillName 'missing-desc' -FrontmatterContent $frontmatter
339
340 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
341 $result.IsValid | Should -BeFalse
342 $result.Errors | Should -Contain ($result.Errors | Where-Object { $_ -like "*missing required 'description'*" })
343 }
344
345 It 'Reports error when name does not match directory name' {
346 $frontmatter = @"
347---
348name: wrong-name
349description: 'Mismatched name skill'
350---
351
352# Wrong Name
353"@
354 $dir = New-TestSkillDirectory -SkillName 'actual-name' -FrontmatterContent $frontmatter
355
356 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
357 $result.IsValid | Should -BeFalse
358 $result.Errors | Should -Contain ($result.Errors | Where-Object { $_ -like "*does not match directory name*" })
359 }
360
361 It 'Reports both errors when name and description are missing' {
362 $frontmatter = @"
363---
364some-other-key: value
365---
366
367# Both Missing
368"@
369 $dir = New-TestSkillDirectory -SkillName 'both-missing' -FrontmatterContent $frontmatter
370
371 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
372 $result.IsValid | Should -BeFalse
373 $result.Errors.Count | Should -BeGreaterOrEqual 2
374 $result.Errors | Where-Object { $_ -like "*missing required 'name'*" } | Should -Not -BeNullOrEmpty
375 $result.Errors | Where-Object { $_ -like "*missing required 'description'*" } | Should -Not -BeNullOrEmpty
376 }
377
378 It 'Reports error when name is empty string' {
379 $frontmatter = @"
380---
381name: ''
382description: 'Has empty name'
383---
384
385# Empty Name
386"@
387 $dir = New-TestSkillDirectory -SkillName 'empty-name' -FrontmatterContent $frontmatter
388
389 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
390 $result.IsValid | Should -BeFalse
391 $result.Errors | Where-Object { $_ -like "*missing required 'name'*" } | Should -Not -BeNullOrEmpty
392 }
393 }
394
395 Context 'Scripts subdirectory checks' {
396 It 'Reports error when scripts/ directory is empty (no .ps1 or .sh files)' {
397 $frontmatter = @"
398---
399name: empty-scripts
400description: 'Skill with empty scripts dir'
401---
402
403# Empty Scripts
404"@
405 $dir = New-TestSkillDirectory -SkillName 'empty-scripts' -FrontmatterContent $frontmatter -WithEmptyScriptsDir
406
407 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
408 $result.IsValid | Should -BeFalse
409 $result.Errors | Should -HaveCount 1
410 $result.Errors[0] | Should -BeLike '*scripts*no .ps1 or .sh*'
411 }
412
413 It 'Reports error when scripts/ contains only .sh file (missing .ps1)' {
414 $frontmatter = @"
415---
416name: sh-only-scripts
417description: 'Skill with sh script only'
418---
419
420# SH Only Scripts
421"@
422 $dir = New-TestSkillDirectory -SkillName 'sh-only-scripts' -FrontmatterContent $frontmatter -WithScriptsDir
423
424 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
425 $result.IsValid | Should -BeFalse
426 $result.Errors | Should -HaveCount 1
427 $result.Errors[0] | Should -BeLike '*scripts*missing a required .ps1*'
428 }
429
430 It 'Reports error when scripts/ contains only .ps1 file (missing .sh)' {
431 $frontmatter = @"
432---
433name: ps1-only-scripts
434description: 'Skill with ps1 script only'
435---
436
437# PS1 Only Scripts
438"@
439 $dir = New-TestSkillDirectory -SkillName 'ps1-only-scripts' -FrontmatterContent $frontmatter
440 $scriptsDir = Join-Path $dir.FullName 'scripts'
441 New-Item -ItemType Directory -Path $scriptsDir -Force | Out-Null
442 Set-Content -Path (Join-Path $scriptsDir 'run.ps1') -Value 'Write-Host "hello"'
443
444 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
445 $result.IsValid | Should -BeFalse
446 $result.Errors | Should -HaveCount 1
447 $result.Errors[0] | Should -BeLike '*scripts*missing a required .sh*'
448 }
449
450 It 'Passes when scripts/ contains both .ps1 and .sh files' {
451 $frontmatter = @"
452---
453name: both-scripts
454description: 'Skill with both script types'
455---
456
457# Both Scripts
458"@
459 $dir = New-TestSkillDirectory -SkillName 'both-scripts' -FrontmatterContent $frontmatter
460 $scriptsDir = Join-Path $dir.FullName 'scripts'
461 New-Item -ItemType Directory -Path $scriptsDir -Force | Out-Null
462 Set-Content -Path (Join-Path $scriptsDir 'run.ps1') -Value 'Write-Host "hello"'
463 Set-Content -Path (Join-Path $scriptsDir 'run.sh') -Value '#!/bin/bash'
464
465 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
466 $result.IsValid | Should -BeTrue
467 $result.Errors | Should -HaveCount 0
468 }
469
470 It 'Passes when no scripts/ directory exists (scripts are optional)' {
471 $frontmatter = @"
472---
473name: no-scripts-dir
474description: 'Skill without scripts directory'
475---
476
477# No Scripts Dir
478"@
479 $dir = New-TestSkillDirectory -SkillName 'no-scripts-dir' -FrontmatterContent $frontmatter
480
481 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
482 $result.IsValid | Should -BeTrue
483 $result.Errors | Should -HaveCount 0
484 }
485 }
486
487 Context 'Unrecognized subdirectories' {
488 It 'Warns about unrecognized subdirectory' {
489 $frontmatter = @"
490---
491name: unrecognized-dir
492description: 'Skill with unknown dir'
493---
494
495# Unrecognized Dir
496"@
497 $dir = New-TestSkillDirectory -SkillName 'unrecognized-dir' -FrontmatterContent $frontmatter -WithUnrecognizedDir
498
499 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
500 $result.IsValid | Should -BeTrue
501 $result.Warnings | Should -HaveCount 1
502 $result.Warnings[0] | Should -BeLike "*Unrecognized subdirectory 'random-dir'*"
503 }
504
505 It 'Does not warn about recognized optional directories' {
506 $frontmatter = @"
507---
508name: recognized-dirs
509description: 'Skill with recognized dirs'
510---
511
512# Recognized Dirs
513"@
514 $dir = New-TestSkillDirectory -SkillName 'recognized-dirs' -FrontmatterContent $frontmatter -OptionalDirs @('scripts', 'references', 'assets', 'examples')
515 # Add both script types so scripts/ passes validation
516 Set-Content -Path (Join-Path $dir.FullName 'scripts/run.sh') -Value '#!/bin/bash'
517 Set-Content -Path (Join-Path $dir.FullName 'scripts/run.ps1') -Value 'Write-Host "hello"'
518
519 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
520 $result.IsValid | Should -BeTrue
521 $result.Warnings | Should -HaveCount 0
522 }
523 }
524
525 Context 'Result object structure' {
526 It 'Returns correct SkillPath as relative path' {
527 $frontmatter = @"
528---
529name: path-check
530description: 'Path check skill'
531---
532
533# Path Check
534"@
535 $dir = New-TestSkillDirectory -SkillName 'path-check' -FrontmatterContent $frontmatter
536
537 $result = Test-SkillDirectory -Directory $dir -RepoRoot $script:SkillTestDir
538 $result.SkillPath | Should -BeExactly 'path-check'
539 }
540 }
541}
542
543#endregion
544
545#region Get-ChangedSkillDirectories Tests
546
547Describe 'Get-ChangedSkillDirectories' -Tag 'Unit' {
548 Context 'Changed files in skill directories' {
549 It 'Returns skill name for changed file in skill directory' {
550 Mock Get-ChangedFilesFromGit {
551 return @('.github/skills/video-to-gif/SKILL.md')
552 }
553
554 $result = Get-ChangedSkillDirectories -BaseBranch 'origin/main' -SkillsPath '.github/skills'
555 $result | Should -Contain 'video-to-gif'
556 }
557
558 It 'Returns empty when changed files are outside skills directory' {
559 Mock Get-ChangedFilesFromGit {
560 return @('scripts/linting/Test.ps1', 'docs/README.md')
561 }
562
563 $result = Get-ChangedSkillDirectories -BaseBranch 'origin/main' -SkillsPath '.github/skills'
564 @($result).Count | Should -Be 0
565 }
566
567 It 'Returns unique skill name for multiple changed files in same skill' {
568 Mock Get-ChangedFilesFromGit {
569 return @(
570 '.github/skills/my-skill/SKILL.md',
571 '.github/skills/my-skill/scripts/run.sh',
572 '.github/skills/my-skill/references/doc.md'
573 )
574 }
575
576 $result = Get-ChangedSkillDirectories -BaseBranch 'origin/main' -SkillsPath '.github/skills'
577 $result | Should -HaveCount 1
578 $result | Should -Contain 'my-skill'
579 }
580
581 It 'Returns empty when no files are changed' {
582 Mock Get-ChangedFilesFromGit { return @() }
583
584 $result = Get-ChangedSkillDirectories -BaseBranch 'origin/main' -SkillsPath '.github/skills'
585 @($result).Count | Should -Be 0
586 }
587
588 It 'Returns multiple skill names for changes across different skills' {
589 Mock Get-ChangedFilesFromGit {
590 return @(
591 '.github/skills/skill-a/SKILL.md',
592 '.github/skills/skill-b/scripts/run.sh'
593 )
594 }
595
596 $result = Get-ChangedSkillDirectories -BaseBranch 'origin/main' -SkillsPath '.github/skills'
597 $result | Should -HaveCount 2
598 $result | Should -Contain 'skill-a'
599 $result | Should -Contain 'skill-b'
600 }
601 }
602
603 Context 'Delegation to Get-ChangedFilesFromGit' {
604 It 'Passes BaseBranch to Get-ChangedFilesFromGit' {
605 Mock Get-ChangedFilesFromGit { return @() }
606
607 Get-ChangedSkillDirectories -BaseBranch 'develop' -SkillsPath '.github/skills'
608 Should -Invoke Get-ChangedFilesFromGit -Times 1 -ParameterFilter {
609 $BaseBranch -eq 'develop'
610 }
611 }
612 }
613
614 Context 'Path normalization' {
615 It 'Handles backslash paths in changed files' {
616 Mock Get-ChangedFilesFromGit {
617 return @('.github\skills\backslash-skill\SKILL.md')
618 }
619
620 $result = Get-ChangedSkillDirectories -BaseBranch 'origin/main' -SkillsPath '.github/skills'
621 $result | Should -Contain 'backslash-skill'
622 }
623 }
624}
625
626#endregion
627
628#region Write-SkillValidationResults Tests
629
630Describe 'Write-SkillValidationResults' -Tag 'Unit' {
631 BeforeAll {
632 $script:ResultsTestDir = Join-Path $script:TempTestDir 'results-tests'
633 New-Item -ItemType Directory -Path $script:ResultsTestDir -Force | Out-Null
634
635 # Clear CI env so Test-CIEnvironment returns false
636 Clear-MockCIEnvironment
637 }
638
639 Context 'JSON output' {
640 It 'Creates JSON file in logs directory for passing results' {
641 $repoRoot = Join-Path $script:ResultsTestDir 'pass-repo'
642 New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
643
644 $results = @(
645 [PSCustomObject]@{
646 SkillName = 'passing-skill'
647 SkillPath = '.github/skills/passing-skill'
648 IsValid = $true
649 Errors = [string[]]@()
650 Warnings = [string[]]@()
651 }
652 )
653
654 Write-SkillValidationResults -Results $results -RepoRoot $repoRoot
655
656 $jsonPath = Join-Path $repoRoot 'logs/skill-validation-results.json'
657 Test-Path $jsonPath | Should -BeTrue
658
659 $json = Get-Content $jsonPath -Raw | ConvertFrom-Json
660 $json.totalSkills | Should -Be 1
661 $json.skillErrors | Should -Be 0
662 $json.skillWarnings | Should -Be 0
663 $json.results[0].skillName | Should -BeExactly 'passing-skill'
664 $json.results[0].isValid | Should -BeTrue
665 }
666
667 It 'Creates JSON file with error details for failing results' {
668 $repoRoot = Join-Path $script:ResultsTestDir 'fail-repo'
669 New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
670
671 $results = @(
672 [PSCustomObject]@{
673 SkillName = 'failing-skill'
674 SkillPath = '.github/skills/failing-skill'
675 IsValid = $false
676 Errors = [string[]]@('SKILL.md is missing')
677 Warnings = [string[]]@()
678 },
679 [PSCustomObject]@{
680 SkillName = 'warning-skill'
681 SkillPath = '.github/skills/warning-skill'
682 IsValid = $true
683 Errors = [string[]]@()
684 Warnings = [string[]]@('Unrecognized subdirectory')
685 }
686 )
687
688 Write-SkillValidationResults -Results $results -RepoRoot $repoRoot
689
690 $jsonPath = Join-Path $repoRoot 'logs/skill-validation-results.json'
691 Test-Path $jsonPath | Should -BeTrue
692
693 $json = Get-Content $jsonPath -Raw | ConvertFrom-Json
694 $json.totalSkills | Should -Be 2
695 $json.skillErrors | Should -Be 1
696 $json.skillWarnings | Should -Be 1
697 $json.results[0].isValid | Should -BeFalse
698 $json.results[0].errors | Should -HaveCount 1
699 }
700
701 It 'Creates logs directory if it does not exist' {
702 $repoRoot = Join-Path $script:ResultsTestDir 'new-logs-repo'
703 New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
704 # Ensure logs dir does not exist
705 $logsDir = Join-Path $repoRoot 'logs'
706 if (Test-Path $logsDir) {
707 Remove-Item $logsDir -Recurse -Force
708 }
709
710 $results = @(
711 [PSCustomObject]@{
712 SkillName = 'create-logs'
713 SkillPath = '.github/skills/create-logs'
714 IsValid = $true
715 Errors = [string[]]@()
716 Warnings = [string[]]@()
717 }
718 )
719
720 Write-SkillValidationResults -Results $results -RepoRoot $repoRoot
721
722 Test-Path $logsDir | Should -BeTrue
723 Test-Path (Join-Path $logsDir 'skill-validation-results.json') | Should -BeTrue
724 }
725
726 It 'Includes timestamp in JSON output' {
727 $repoRoot = Join-Path $script:ResultsTestDir 'timestamp-repo'
728 New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
729
730 $results = @(
731 [PSCustomObject]@{
732 SkillName = 'ts-skill'
733 SkillPath = '.github/skills/ts-skill'
734 IsValid = $true
735 Errors = [string[]]@()
736 Warnings = [string[]]@()
737 }
738 )
739
740 Write-SkillValidationResults -Results $results -RepoRoot $repoRoot
741
742 $jsonPath = Join-Path $repoRoot 'logs/skill-validation-results.json'
743 $json = Get-Content $jsonPath -Raw | ConvertFrom-Json
744 $json.timestamp | Should -Not -BeNullOrEmpty
745 }
746 }
747
748 Context 'CI annotations' {
749 It 'Emits CI annotations when in CI environment' {
750 $repoRoot = Join-Path $script:ResultsTestDir 'ci-repo'
751 New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
752 $mockFiles = Initialize-MockCIEnvironment
753
754 try {
755 $results = @(
756 [PSCustomObject]@{
757 SkillName = 'ci-fail'
758 SkillPath = '.github/skills/ci-fail'
759 IsValid = $false
760 Errors = [string[]]@('Missing SKILL.md')
761 Warnings = [string[]]@('Empty scripts dir')
762 }
763 )
764
765 # Capture all output; CI annotations go to stdout via Write-Output
766 $null = Write-SkillValidationResults -Results $results -RepoRoot $repoRoot 6>&1
767
768 $jsonPath = Join-Path $repoRoot 'logs/skill-validation-results.json'
769 Test-Path $jsonPath | Should -BeTrue
770 }
771 finally {
772 Clear-MockCIEnvironment
773 Remove-MockCIFiles -MockFiles $mockFiles
774 }
775 }
776 }
777}
778
779#endregion
780
781#region Console output verification
782
783Describe 'Write-SkillValidationResults console output' -Tag 'Unit' {
784 BeforeAll {
785 Clear-MockCIEnvironment
786 }
787
788 Context 'Status indicators' {
789 It 'Shows green check for fully passing skill' {
790 $repoRoot = Join-Path $script:TempTestDir 'console-pass'
791 New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
792
793 $results = @(
794 [PSCustomObject]@{
795 SkillName = 'good-skill'
796 SkillPath = '.github/skills/good-skill'
797 IsValid = $true
798 Errors = [string[]]@()
799 Warnings = [string[]]@()
800 }
801 )
802
803 # Should not throw
804 { Write-SkillValidationResults -Results $results -RepoRoot $repoRoot } | Should -Not -Throw
805 }
806
807 It 'Shows warning indicator for skill with warnings only' {
808 $repoRoot = Join-Path $script:TempTestDir 'console-warn'
809 New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
810
811 $results = @(
812 [PSCustomObject]@{
813 SkillName = 'warn-skill'
814 SkillPath = '.github/skills/warn-skill'
815 IsValid = $true
816 Errors = [string[]]@()
817 Warnings = [string[]]@('Some warning')
818 }
819 )
820
821 { Write-SkillValidationResults -Results $results -RepoRoot $repoRoot } | Should -Not -Throw
822 }
823
824 It 'Shows error indicator for failing skill' {
825 $repoRoot = Join-Path $script:TempTestDir 'console-fail'
826 New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
827
828 $results = @(
829 [PSCustomObject]@{
830 SkillName = 'bad-skill'
831 SkillPath = '.github/skills/bad-skill'
832 IsValid = $false
833 Errors = [string[]]@('Something broke')
834 Warnings = [string[]]@()
835 }
836 )
837
838 { Write-SkillValidationResults -Results $results -RepoRoot $repoRoot } | Should -Not -Throw
839 }
840 }
841}
842
843#endregion
844
845#region Invoke-SkillStructureValidation Tests
846
847Describe 'Invoke-SkillStructureValidation' -Tag 'Unit' {
848 BeforeAll {
849 $script:ValidationDir = Join-Path ([System.IO.Path]::GetTempPath()) "SkillValidation_$([guid]::NewGuid().ToString('N'))"
850 New-Item -ItemType Directory -Path $script:ValidationDir -Force | Out-Null
851
852 # Clear CI env to avoid annotation output interference
853 Clear-MockCIEnvironment
854 }
855
856 AfterAll {
857 if ($script:ValidationDir -and (Test-Path $script:ValidationDir)) {
858 Remove-Item -Path $script:ValidationDir -Recurse -Force -ErrorAction SilentlyContinue
859 }
860 }
861
862 Context 'Skills directory does not exist' {
863 It 'Returns 0 when skills path does not exist' {
864 Mock git {
865 $global:LASTEXITCODE = 0
866 return $script:ValidationDir
867 } -ParameterFilter { $args[0] -eq 'rev-parse' }
868
869 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'nonexistent-skills'
870 $exitCode | Should -Be 0
871 }
872 }
873
874 Context 'Empty skills directory' {
875 It 'Returns 0 when skills directory has no subdirectories' {
876 $emptyDir = Join-Path $script:ValidationDir 'empty-skills'
877 New-Item -ItemType Directory -Path $emptyDir -Force | Out-Null
878
879 Mock git {
880 $global:LASTEXITCODE = 0
881 return $script:ValidationDir
882 } -ParameterFilter { $args[0] -eq 'rev-parse' }
883
884 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'empty-skills'
885 $exitCode | Should -Be 0
886 }
887 }
888
889 Context 'Valid skill directories' {
890 It 'Returns 0 for a valid skill with proper SKILL.md' {
891 $skillsDir = Join-Path $script:ValidationDir 'valid-skills'
892 $skillDir = Join-Path $skillsDir 'good-skill'
893 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
894 $content = @"
895---
896name: good-skill
897description: 'A valid skill for integration testing'
898---
899
900# Good Skill
901"@
902 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
903
904 Mock git {
905 $global:LASTEXITCODE = 0
906 return $script:ValidationDir
907 } -ParameterFilter { $args[0] -eq 'rev-parse' }
908
909 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'valid-skills'
910 $exitCode | Should -Be 0
911 }
912 }
913
914 Context 'Nested collection-based skill directories' {
915 It 'Returns 0 for a valid skill nested under a collection-id directory' {
916 $skillsDir = Join-Path $script:ValidationDir 'nested-valid'
917 $skillDir = Join-Path $skillsDir 'my-collection/my-nested-skill'
918 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
919 $content = @"
920---
921name: my-nested-skill
922description: 'A valid nested skill under a collection directory'
923---
924
925# My Nested Skill
926"@
927 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
928
929 Mock git {
930 $global:LASTEXITCODE = 0
931 return $script:ValidationDir
932 } -ParameterFilter { $args[0] -eq 'rev-parse' }
933
934 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'nested-valid'
935 $exitCode | Should -Be 0
936 }
937 }
938
939 Context 'Invalid skill directories' {
940 It 'Returns 0 when a directory has no SKILL.md (file-driven discovery)' {
941 $skillsDir = Join-Path $script:ValidationDir 'invalid-missing'
942 $skillDir = Join-Path $skillsDir 'broken-skill'
943 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
944
945 Mock git {
946 $global:LASTEXITCODE = 0
947 return $script:ValidationDir
948 } -ParameterFilter { $args[0] -eq 'rev-parse' }
949
950 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'invalid-missing'
951 $exitCode | Should -Be 0
952 }
953
954 It 'Returns 1 when SKILL.md has no frontmatter' {
955 $skillsDir = Join-Path $script:ValidationDir 'invalid-nofm'
956 $skillDir = Join-Path $skillsDir 'no-fm-skill'
957 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
958 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value '# Just a heading'
959
960 Mock git {
961 $global:LASTEXITCODE = 0
962 return $script:ValidationDir
963 } -ParameterFilter { $args[0] -eq 'rev-parse' }
964
965 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'invalid-nofm'
966 $exitCode | Should -Be 1
967 }
968
969 It 'Returns 1 when frontmatter name does not match directory' {
970 $skillsDir = Join-Path $script:ValidationDir 'invalid-mismatch'
971 $skillDir = Join-Path $skillsDir 'real-name'
972 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
973 $content = @"
974---
975name: wrong-name
976description: 'Mismatched name'
977---
978
979# Wrong Name
980"@
981 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
982
983 Mock git {
984 $global:LASTEXITCODE = 0
985 return $script:ValidationDir
986 } -ParameterFilter { $args[0] -eq 'rev-parse' }
987
988 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'invalid-mismatch'
989 $exitCode | Should -Be 1
990 }
991 }
992
993 Context 'WarningsAsErrors flag' {
994 It 'Returns 1 when WarningsAsErrors and skill has warnings' {
995 $skillsDir = Join-Path $script:ValidationDir 'warn-as-error'
996 $skillDir = Join-Path $skillsDir 'warn-skill'
997 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
998 $content = @"
999---
1000name: warn-skill
1001description: 'Skill with unrecognized dir'
1002---
1003
1004# Warn Skill
1005"@
1006 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
1007 New-Item -ItemType Directory -Path (Join-Path $skillDir 'custom-dir') -Force | Out-Null
1008
1009 Mock git {
1010 $global:LASTEXITCODE = 0
1011 return $script:ValidationDir
1012 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1013
1014 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'warn-as-error' -WarningsAsErrors
1015 $exitCode | Should -Be 1
1016 }
1017
1018 It 'Returns 0 when WarningsAsErrors but no warnings' {
1019 $skillsDir = Join-Path $script:ValidationDir 'nowarn'
1020 $skillDir = Join-Path $skillsDir 'clean-skill'
1021 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
1022 $content = @"
1023---
1024name: clean-skill
1025description: 'Clean skill with no warnings'
1026---
1027
1028# Clean Skill
1029"@
1030 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
1031
1032 Mock git {
1033 $global:LASTEXITCODE = 0
1034 return $script:ValidationDir
1035 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1036
1037 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'nowarn' -WarningsAsErrors
1038 $exitCode | Should -Be 0
1039 }
1040 }
1041
1042 Context 'ChangedFilesOnly mode' {
1043 It 'Returns 0 when no skill files changed' {
1044 Mock git {
1045 $global:LASTEXITCODE = 0
1046 return $script:ValidationDir
1047 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1048
1049 Mock Get-ChangedSkillDirectories {
1050 return @()
1051 }
1052
1053 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'skills' -ChangedFilesOnly
1054 $exitCode | Should -Be 0
1055 }
1056
1057 It 'Returns 0 for valid changed skill' {
1058 $skillsDir = Join-Path $script:ValidationDir 'changed-valid'
1059 $skillDir = Join-Path $skillsDir 'my-skill'
1060 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
1061 $content = @"
1062---
1063name: my-skill
1064description: 'Valid changed skill'
1065---
1066
1067# My Skill
1068"@
1069 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
1070
1071 Mock git {
1072 $global:LASTEXITCODE = 0
1073 return $script:ValidationDir
1074 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1075
1076 Mock Get-ChangedSkillDirectories {
1077 return [string[]]@('my-skill')
1078 }
1079
1080 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'changed-valid' -ChangedFilesOnly
1081 $exitCode | Should -Be 0
1082 }
1083
1084 It 'Returns 1 when changed skill has errors' {
1085 $skillsDir = Join-Path $script:ValidationDir 'changed-invalid'
1086 $skillDir = Join-Path $skillsDir 'bad-skill'
1087 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
1088 $content = @"
1089---
1090name: bad-skill
1091---
1092
1093# Bad Skill
1094"@
1095 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
1096
1097 Mock git {
1098 $global:LASTEXITCODE = 0
1099 return $script:ValidationDir
1100 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1101
1102 Mock Get-ChangedSkillDirectories {
1103 return [string[]]@('bad-skill')
1104 }
1105
1106 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'changed-invalid' -ChangedFilesOnly
1107 $exitCode | Should -Be 1
1108 }
1109
1110 It 'Returns 0 and skips validation when changed skill was deleted' {
1111 $skillsDir = Join-Path $script:ValidationDir 'changed-deleted'
1112 New-Item -ItemType Directory -Path $skillsDir -Force | Out-Null
1113
1114 Mock git {
1115 $global:LASTEXITCODE = 0
1116 return $script:ValidationDir
1117 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1118
1119 Mock Get-ChangedSkillDirectories {
1120 return [string[]]@('deleted-skill')
1121 }
1122
1123 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'changed-deleted' -ChangedFilesOnly
1124 $exitCode | Should -Be 0
1125 }
1126 }
1127
1128 Context 'Multiple skills with mixed results' {
1129 It 'Returns 0 when valid skills exist alongside empty directories' {
1130 $skillsDir = Join-Path $script:ValidationDir 'mixed'
1131 New-Item -ItemType Directory -Path $skillsDir -Force | Out-Null
1132
1133 # Valid skill
1134 $validDir = Join-Path $skillsDir 'alpha-skill'
1135 New-Item -ItemType Directory -Path $validDir -Force | Out-Null
1136 $validContent = @"
1137---
1138name: alpha-skill
1139description: 'Valid skill'
1140---
1141
1142# Alpha Skill
1143"@
1144 Set-Content -Path (Join-Path $validDir 'SKILL.md') -Value $validContent
1145
1146 # Empty directory (no SKILL.md) — file-driven discovery skips it
1147 $emptyDir = Join-Path $skillsDir 'beta-skill'
1148 New-Item -ItemType Directory -Path $emptyDir -Force | Out-Null
1149
1150 Mock git {
1151 $global:LASTEXITCODE = 0
1152 return $script:ValidationDir
1153 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1154
1155 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'mixed'
1156 $exitCode | Should -Be 0
1157 }
1158
1159 It 'Returns 1 when at least one skill has frontmatter errors' {
1160 $skillsDir = Join-Path $script:ValidationDir 'mixed-errors'
1161 New-Item -ItemType Directory -Path $skillsDir -Force | Out-Null
1162
1163 # Valid skill
1164 $validDir = Join-Path $skillsDir 'good-skill'
1165 New-Item -ItemType Directory -Path $validDir -Force | Out-Null
1166 $validContent = @"
1167---
1168name: good-skill
1169description: 'Valid skill'
1170---
1171
1172# Good Skill
1173"@
1174 Set-Content -Path (Join-Path $validDir 'SKILL.md') -Value $validContent
1175
1176 # Invalid skill (SKILL.md exists but has bad frontmatter)
1177 $invalidDir = Join-Path $skillsDir 'bad-skill'
1178 New-Item -ItemType Directory -Path $invalidDir -Force | Out-Null
1179 Set-Content -Path (Join-Path $invalidDir 'SKILL.md') -Value '# No frontmatter'
1180
1181 Mock git {
1182 $global:LASTEXITCODE = 0
1183 return $script:ValidationDir
1184 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1185
1186 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'mixed-errors'
1187 $exitCode | Should -Be 1
1188 }
1189 }
1190
1191 Context 'Repo root resolution' {
1192 It 'Uses git rev-parse when available' {
1193 $repoRoot = Join-Path $script:ValidationDir 'git-repo'
1194 $skillsDir = Join-Path $repoRoot '.github/skills'
1195 $skillDir = Join-Path $skillsDir 'repo-skill'
1196 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
1197 $content = @"
1198---
1199name: repo-skill
1200description: 'Skill in git repo'
1201---
1202
1203# Repo Skill
1204"@
1205 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
1206
1207 Mock git {
1208 $global:LASTEXITCODE = 0
1209 return $repoRoot
1210 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1211
1212 $exitCode = Invoke-SkillStructureValidation -SkillsPath '.github/skills'
1213 $exitCode | Should -Be 0
1214 }
1215
1216 It 'Falls back to current directory when git rev-parse fails' {
1217 $repoRoot = Join-Path $script:ValidationDir 'fallback-repo'
1218 $skillsDir = Join-Path $repoRoot 'my-skills'
1219 $skillDir = Join-Path $skillsDir 'fb-skill'
1220 New-Item -ItemType Directory -Path $skillDir -Force | Out-Null
1221 $content = @"
1222---
1223name: fb-skill
1224description: 'Fallback skill'
1225---
1226
1227# Fallback Skill
1228"@
1229 Set-Content -Path (Join-Path $skillDir 'SKILL.md') -Value $content
1230
1231 Mock git {
1232 $global:LASTEXITCODE = 128
1233 return $null
1234 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1235
1236 Push-Location $repoRoot
1237 try {
1238 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'my-skills'
1239 $exitCode | Should -Be 0
1240 }
1241 finally {
1242 Pop-Location
1243 }
1244 }
1245 }
1246
1247 Context 'Error handling' {
1248 It 'Returns 1 when an unexpected error occurs' {
1249 Mock git {
1250 $global:LASTEXITCODE = 0
1251 return $script:ValidationDir
1252 } -ParameterFilter { $args[0] -eq 'rev-parse' }
1253
1254 # Create the skills directory so the first Test-Path passes
1255 $errSkillsDir = Join-Path $script:ValidationDir 'error-skills'
1256 New-Item -ItemType Directory -Path $errSkillsDir -Force | Out-Null
1257
1258 # Mock Get-ChildItem to throw to trigger catch block
1259 Mock Get-ChildItem {
1260 throw 'Simulated filesystem error'
1261 }
1262
1263 $exitCode = Invoke-SkillStructureValidation -SkillsPath 'error-skills' 2>&1 |
1264 Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] }
1265 $exitCode | Should -Contain 1
1266 }
1267 }
1268}
1269
1270#endregion
1271