microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/1873-devcontainer

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/evals/Modules/EvalSpecSchema.psm1

255lines · modecode

1# Copyright (c) Microsoft Corporation.
2# SPDX-License-Identifier: MIT
3
4# EvalSpecSchema.psm1
5#
6# Purpose: Schema validation helpers for vally eval spec files under evals/.
7# Author: HVE Core Team
8
9#Requires -Version 7.0
10
11Set-StrictMode -Version Latest
12
13$script:AllowedExecutors = @('copilot-sdk')
14$script:BacklinkTagKinds = @{
15 skill = @{ Glob = '.github/skills/**/{0}/SKILL.md' }
16 agent = @{ Glob = '.github/agents/**/{0}.agent.md' }
17 prompt = @{ Glob = '.github/prompts/**/{0}.prompt.md' }
18 instruction = @{ Glob = '.github/instructions/**/{0}.instructions.md' }
19}
20
21function Resolve-EvalArtifactPath {
22 <#
23 .SYNOPSIS
24 Resolves a stimulus backlink tag value to a concrete artifact path under .github/.
25
26 .DESCRIPTION
27 Locates the artifact file for a given backlink kind (skill/agent/prompt/instruction)
28 and slug by globbing the appropriate directory tree under the repository's .github/.
29
30 .PARAMETER RepoRoot
31 Absolute path to the repository root.
32
33 .PARAMETER Kind
34 Backlink kind. One of: skill, agent, prompt, instruction.
35
36 .PARAMETER Slug
37 Artifact slug as referenced by the stimulus tag value.
38
39 .OUTPUTS
40 [string] Workspace-relative artifact path when found, otherwise $null.
41 #>
42 [CmdletBinding()]
43 [OutputType([string])]
44 param(
45 [Parameter(Mandatory = $true)]
46 [ValidateNotNullOrEmpty()]
47 [string]$RepoRoot,
48
49 [Parameter(Mandatory = $true)]
50 [ValidateSet('skill', 'agent', 'prompt', 'instruction')]
51 [string]$Kind,
52
53 [Parameter(Mandatory = $true)]
54 [ValidateNotNullOrEmpty()]
55 [string]$Slug
56 )
57
58 if (-not $script:BacklinkTagKinds.ContainsKey($Kind)) {
59 return $null
60 }
61
62 # Glob via Get-ChildItem since Join-Path does not expand wildcard segments.
63 $githubRoot = Join-Path -Path $RepoRoot -ChildPath '.github'
64 if (-not (Test-Path -LiteralPath $githubRoot -PathType Container)) {
65 return $null
66 }
67
68 $leafPattern = switch ($Kind) {
69 'skill' { 'SKILL.md' }
70 'agent' { "$Slug.agent.md" }
71 'prompt' { "$Slug.prompt.md" }
72 'instruction' { "$Slug.instructions.md" }
73 }
74
75 $candidates = Get-ChildItem -LiteralPath $githubRoot -Recurse -File -Filter $leafPattern -ErrorAction SilentlyContinue
76 foreach ($candidate in $candidates) {
77 if ($Kind -eq 'skill') {
78 $parentName = Split-Path -Path (Split-Path -Path $candidate.FullName -Parent) -Leaf
79 if ($parentName -ne $Slug) { continue }
80 }
81 $relPath = ($candidate.FullName.Substring($RepoRoot.Length)).TrimStart('\', '/').Replace('\', '/')
82 return $relPath
83 }
84
85 return $null
86}
87
88function Test-EvalSpecCompliance {
89 <#
90 .SYNOPSIS
91 Validates a parsed eval spec against the embedded schema.
92
93 .DESCRIPTION
94 Checks required top-level keys, executor whitelist, per-stimulus required keys
95 (name, prompt, graders), and per-stimulus backlink tags (skill/agent/prompt/instruction)
96 when present. Returns a list of errors with `path` and `message` for each violation.
97
98 .PARAMETER Spec
99 Parsed eval spec object (from ConvertFrom-Yaml).
100
101 .PARAMETER SpecPath
102 Workspace-relative path to the spec file, used for error annotations.
103
104 .PARAMETER RepoRoot
105 Absolute path to the repository root, used to resolve backlink artifacts.
106
107 .OUTPUTS
108 [System.Collections.Generic.List[hashtable]] List of error records with `path` and `message`.
109 #>
110 [CmdletBinding()]
111 [OutputType([System.Collections.Generic.List[hashtable]])]
112 param(
113 [Parameter(Mandatory = $true)]
114 [AllowNull()]
115 $Spec,
116
117 [Parameter(Mandatory = $true)]
118 [ValidateNotNullOrEmpty()]
119 [string]$SpecPath,
120
121 [Parameter(Mandatory = $true)]
122 [ValidateNotNullOrEmpty()]
123 [string]$RepoRoot
124 )
125
126 $errors = [System.Collections.Generic.List[hashtable]]::new()
127
128 if ($null -eq $Spec) {
129 $errors.Add(@{ path = $SpecPath; field = '<root>'; message = 'Spec is empty or could not be parsed' })
130 return $errors
131 }
132
133 if (-not ($Spec -is [hashtable] -or $Spec -is [System.Collections.IDictionary])) {
134 $errors.Add(@{ path = $SpecPath; field = '<root>'; message = 'Top-level YAML must be a mapping' })
135 return $errors
136 }
137
138 if (-not $Spec.ContainsKey('name') -or [string]::IsNullOrWhiteSpace([string]$Spec['name'])) {
139 $errors.Add(@{ path = $SpecPath; field = 'name'; message = 'Missing required key: name' })
140 }
141
142 $executor = $null
143 if ($Spec.ContainsKey('config') -and $Spec['config'] -is [System.Collections.IDictionary]) {
144 if ($Spec['config'].ContainsKey('executor')) {
145 $executor = [string]$Spec['config']['executor']
146 }
147 }
148 if ([string]::IsNullOrWhiteSpace($executor)) {
149 $errors.Add(@{ path = $SpecPath; field = 'config.executor'; message = 'Missing required key: config.executor' })
150 }
151 elseif ($script:AllowedExecutors -notcontains $executor) {
152 $allowed = $script:AllowedExecutors -join ', '
153 $errors.Add(@{ path = $SpecPath; field = 'config.executor'; message = "Executor '$executor' is not in the whitelist ($allowed)" })
154 }
155
156 if ($Spec.ContainsKey('moderation')) {
157 $moderation = $Spec['moderation']
158 if (-not ($moderation -is [System.Collections.IDictionary])) {
159 $errors.Add(@{ path = $SpecPath; field = 'moderation'; message = 'moderation must be a mapping' })
160 }
161 elseif ($moderation.ContainsKey('threshold')) {
162 $thresholdRaw = $moderation['threshold']
163 $thresholdValue = $null
164 $isNumeric = $false
165 if ($thresholdRaw -is [double] -or $thresholdRaw -is [single] -or $thresholdRaw -is [decimal] -or
166 $thresholdRaw -is [int] -or $thresholdRaw -is [long] -or $thresholdRaw -is [byte]) {
167 $thresholdValue = [double]$thresholdRaw
168 $isNumeric = $true
169 }
170 if (-not $isNumeric) {
171 $errors.Add(@{ path = $SpecPath; field = 'moderation.threshold'; message = 'moderation.threshold must be a number between 0.0 and 1.0 inclusive' })
172 }
173 elseif ($thresholdValue -lt 0.0 -or $thresholdValue -gt 1.0) {
174 $errors.Add(@{ path = $SpecPath; field = 'moderation.threshold'; message = "moderation.threshold ($thresholdValue) must be between 0.0 and 1.0 inclusive" })
175 }
176 }
177 }
178
179 if (-not $Spec.ContainsKey('stimuli')) {
180 $errors.Add(@{ path = $SpecPath; field = 'stimuli'; message = 'Missing required key: stimuli' })
181 return $errors
182 }
183
184 $stimuli = $Spec['stimuli']
185 if ($null -eq $stimuli -or -not ($stimuli -is [System.Collections.IEnumerable]) -or $stimuli -is [string]) {
186 $errors.Add(@{ path = $SpecPath; field = 'stimuli'; message = 'stimuli must be a non-empty array' })
187 return $errors
188 }
189
190 $stimulusCount = 0
191 $index = -1
192 foreach ($stimulus in $stimuli) {
193 $index++
194 $stimulusCount++
195 $fieldPrefix = "stimuli[$index]"
196
197 if (-not ($stimulus -is [System.Collections.IDictionary])) {
198 $errors.Add(@{ path = $SpecPath; field = $fieldPrefix; message = 'Stimulus must be a mapping' })
199 continue
200 }
201
202 $stimulusName = if ($stimulus.ContainsKey('name')) { [string]$stimulus['name'] } else { '' }
203 $stimulusLabel = if ([string]::IsNullOrWhiteSpace($stimulusName)) { $fieldPrefix } else { "$fieldPrefix ($stimulusName)" }
204
205 if (-not $stimulus.ContainsKey('name') -or [string]::IsNullOrWhiteSpace($stimulusName)) {
206 $errors.Add(@{ path = $SpecPath; field = "$fieldPrefix.name"; message = 'Stimulus missing required key: name' })
207 }
208
209 if (-not $stimulus.ContainsKey('prompt') -or [string]::IsNullOrWhiteSpace([string]$stimulus['prompt'])) {
210 $errors.Add(@{ path = $SpecPath; field = "$stimulusLabel.prompt"; message = 'Stimulus missing required key: prompt' })
211 }
212
213 $graders = if ($stimulus.ContainsKey('graders')) { $stimulus['graders'] } else { $null }
214 $graderCount = 0
215 if ($graders -is [System.Collections.IEnumerable] -and -not ($graders -is [string])) {
216 foreach ($g in $graders) { $graderCount++ }
217 }
218 if ($graderCount -lt 1) {
219 $errors.Add(@{ path = $SpecPath; field = "$stimulusLabel.graders"; message = 'Stimulus must declare at least one grader (assertion)' })
220 }
221
222 if ($stimulus.ContainsKey('tags') -and $stimulus['tags'] -is [System.Collections.IDictionary]) {
223 foreach ($kind in $script:BacklinkTagKinds.Keys) {
224 if (-not $stimulus['tags'].ContainsKey($kind)) { continue }
225 $tagValue = $stimulus['tags'][$kind]
226 $slugs = if ($tagValue -is [System.Collections.IEnumerable] -and -not ($tagValue -is [string])) {
227 @($tagValue | ForEach-Object { [string]$_ })
228 } else {
229 @([string]$tagValue)
230 }
231 foreach ($slug in $slugs) {
232 if ([string]::IsNullOrWhiteSpace($slug)) {
233 $errors.Add(@{ path = $SpecPath; field = "$stimulusLabel.tags.$kind"; message = "Empty backlink tag '$kind'" })
234 continue
235 }
236 $resolved = Resolve-EvalArtifactPath -RepoRoot $RepoRoot -Kind $kind -Slug $slug
237 if ($null -eq $resolved) {
238 $errors.Add(@{ path = $SpecPath; field = "$stimulusLabel.tags.$kind"; message = "Backlink '$kind=$slug' does not resolve to an artifact under .github/" })
239 }
240 }
241 }
242 }
243 }
244
245 if ($stimulusCount -eq 0) {
246 $errors.Add(@{ path = $SpecPath; field = 'stimuli'; message = 'stimuli array must contain at least one stimulus' })
247 }
248
249 return $errors
250}
251
252Export-ModuleMember -Function @(
253 'Test-EvalSpecCompliance',
254 'Resolve-EvalArtifactPath'
255)
256