Skip to content

Commit 254d445

Browse files
aavetisCopilotBill BerryWilliamBerryiii
authored
fix(scripts): align agent frontmatter schema with VS Code spec (#469)
# Pull Request ## Description Aligns the agent frontmatter JSON schema with the VS Code Custom Agents spec deltas described in #425, and updates the repo's schema validator so the new schema constraints are enforced. ### Implementation Notes - `Test-JsonSchemaValidation` now supports the schema constructs required for this issue (`oneOf`, nested `object` with `required`/`properties`, and `array.items`) - `additionalProperties` is still not enforced by the validator (documented), consistent with prior behavior ### Issue #425 Requirements (Checklist) - [x] Add missing fields to `agent-frontmatter.schema.json`: `agents`, `user-invokable`, `disable-model-invocation` - [x] Fix `model` type to allow string OR array of strings - [x] Make `handoffs.prompt` optional - [x] Add `handoffs.model` (optional string) - [x] Preserve HVE-Core specific `maturity` field ## Related Issue(s) Fixes #425 ## Type of Change Select all that apply: **Code & Documentation:** - [x] Bug fix (non-breaking change fixing an issue) - [ ] New feature (non-breaking change adding functionality) - [ ] Breaking change (fix or feature causing existing functionality to change) - [ ] Documentation update **Infrastructure & Configuration:** - [ ] GitHub Actions workflow - [ ] Linting configuration (markdown, PowerShell, etc.) - [ ] Security configuration - [ ] DevContainer configuration - [ ] Dependency update **AI Artifacts:** - [ ] Reviewed contribution with `prompt-builder` agent and addressed all feedback - [ ] Copilot instructions (`.github/instructions/*.instructions.md`) - [ ] Copilot prompt (`.github/prompts/*.prompt.md`) - [ ] Copilot agent (`.github/agents/*.agent.md`) - [ ] Copilot skill (`.github/skills/*/SKILL.md`) **Other:** - [x] Script/automation (`.ps1`, `.sh`, `.py`) - [ ] Other (please describe): ## Testing Ran locally: - `npm run lint:frontmatter` - `npm run lint:ps` - `npm run test:ps` - `npm run spell-check` - Validated all agents with schema validation enabled: - `pwsh -NoProfile -Command "& scripts/linting/Validate-MarkdownFrontmatter.ps1 -WarningsAsErrors -EnableSchemaValidation -Files (Get-ChildItem -Path .github/agents -Filter *.agent.md -File | Select-Object -ExpandProperty FullName)"` ## Checklist ### Required Checks - [ ] Documentation is updated (if applicable) - [ ] Files follow existing naming conventions - [ ] Changes are backwards compatible (if applicable) - [ ] Tests added for new functionality (if applicable) ### Required Automated Checks The following validation commands must pass before merging: - [x] Markdown linting: `npm run lint:md` - [x] Spell checking: `npm run spell-check` - [x] Frontmatter validation: `npm run lint:frontmatter` - [ ] Skill structure validation: `npm run validate:skills` - [ ] Link validation: `npm run lint:md-links` - [x] PowerShell analysis: `npm run lint:ps` ## Security Considerations - [ ] This PR does not contain any sensitive or NDA information - [ ] Any new dependencies have been reviewed for security issues - [ ] Security-related scripts follow the principle of least privilege ## Additional Notes - Adds `dependency-pinning-artifacts/` to `.gitignore` since it is generated by the dependency pinning scan script during local runs and can otherwise leave the working tree dirty. --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Bill Berry <[email protected]> Co-authored-by: Bill Berry <[email protected]>
1 parent 73c0d2c commit 254d445

4 files changed

Lines changed: 576 additions & 88 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,9 @@ extension/README.*.md
440440
logs/
441441
*.log
442442

443+
# Local artifacts generated by dependency pinning checks
444+
dependency-pinning-artifacts/
445+
443446
# Checkov security scan reports
444447
checkov-results.json
445448
checkov-junit.xml

scripts/linting/Validate-MarkdownFrontmatter.ps1

Lines changed: 240 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,237 @@ function Get-SchemaForFile {
255255
return $null
256256
}
257257

258+
function ConvertTo-ObjectArray {
259+
<#
260+
.SYNOPSIS
261+
Converts an enumerable to an object array, converting nested objects to hashtables.
262+
#>
263+
[CmdletBinding()]
264+
[OutputType([object[]])]
265+
param(
266+
[Parameter(Mandatory = $true)]
267+
[System.Collections.IEnumerable]$Enumerable
268+
)
269+
270+
$list = [System.Collections.Generic.List[object]]::new()
271+
foreach ($item in $Enumerable) {
272+
if ($item -is [pscustomobject] -or $item -is [hashtable]) {
273+
$list.Add((ConvertTo-HashTable -InputObject $item))
274+
}
275+
else {
276+
$list.Add($item)
277+
}
278+
}
279+
280+
# Prevent PowerShell from unrolling single-element arrays when used in expressions/assignments.
281+
return ,$list.ToArray()
282+
}
283+
284+
function ConvertTo-HashTable {
285+
<#
286+
.SYNOPSIS
287+
Converts a PSCustomObject or hashtable to a hashtable recursively.
288+
#>
289+
[CmdletBinding()]
290+
[OutputType([hashtable])]
291+
param(
292+
[Parameter(Mandatory = $true)]
293+
[ValidateScript({ $_ -is [hashtable] -or $_ -is [pscustomobject] })]
294+
[object]$InputObject
295+
)
296+
297+
if ($InputObject -is [hashtable]) {
298+
$out = @{}
299+
foreach ($k in $InputObject.Keys) {
300+
$v = $InputObject[$k]
301+
if ($v -is [pscustomobject] -or $v -is [hashtable]) {
302+
$out[$k] = ConvertTo-HashTable -InputObject $v
303+
}
304+
elseif ($v -is [System.Collections.IEnumerable] -and $v -isnot [string]) {
305+
$out[$k] = ConvertTo-ObjectArray -Enumerable $v
306+
}
307+
else {
308+
$out[$k] = $v
309+
}
310+
}
311+
return $out
312+
}
313+
314+
if ($InputObject -is [pscustomobject]) {
315+
$out = @{}
316+
foreach ($p in $InputObject.PSObject.Properties) {
317+
$v = $p.Value
318+
if ($v -is [pscustomobject] -or $v -is [hashtable]) {
319+
$out[$p.Name] = ConvertTo-HashTable -InputObject $v
320+
}
321+
elseif ($v -is [System.Collections.IEnumerable] -and $v -isnot [string]) {
322+
$out[$p.Name] = ConvertTo-ObjectArray -Enumerable $v
323+
}
324+
else {
325+
$out[$p.Name] = $v
326+
}
327+
}
328+
return $out
329+
}
330+
}
331+
332+
function Test-ValueAgainstSchema {
333+
<#
334+
.SYNOPSIS
335+
Validates a value against a (subset of) JSON schema.
336+
.DESCRIPTION
337+
Supports: type (string/array/boolean/object), required, properties, items, enum, pattern, minLength, oneOf.
338+
Designed for "soft" schema validation; does not implement full JSON Schema.
339+
#>
340+
[CmdletBinding()]
341+
[OutputType([string[]])]
342+
param(
343+
[Parameter(Mandatory = $true)]
344+
[object]$Value,
345+
346+
[Parameter(Mandatory = $true)]
347+
[object]$Schema,
348+
349+
[Parameter(Mandatory = $true)]
350+
[string]$Path
351+
)
352+
353+
$localErrors = [List[string]]::new()
354+
355+
# Handle oneOf by validating against each subschema.
356+
if ($Schema.oneOf) {
357+
$passCount = 0
358+
$subschemaErrors = [System.Collections.Generic.List[object]]::new()
359+
360+
$i = 0
361+
foreach ($sub in $Schema.oneOf) {
362+
$subErrs = Test-ValueAgainstSchema -Value $Value -Schema $sub -Path $Path
363+
if ($subErrs.Count -eq 0) {
364+
$passCount++
365+
if ($passCount -gt 1) { break }
366+
}
367+
else {
368+
# Capture errors per subschema so failures are stable and actionable (not dependent on ordering).
369+
$subschemaErrors.Add(@{ Index = $i; Errors = $subErrs })
370+
}
371+
372+
$i++
373+
}
374+
375+
if ($passCount -ne 1) {
376+
# oneOf semantics: exactly one schema must match
377+
if ($passCount -eq 0) {
378+
$localErrors.Add("Field '$Path' must match one of the allowed schemas")
379+
380+
foreach ($entry in $subschemaErrors) {
381+
$idx = $entry.Index
382+
foreach ($e in $entry.Errors) {
383+
$localErrors.Add("oneOf[$idx]: $e")
384+
}
385+
}
386+
}
387+
else {
388+
$localErrors.Add("Field '$Path' must match exactly one of the allowed schemas")
389+
}
390+
}
391+
392+
return $localErrors.ToArray()
393+
}
394+
395+
# Type validation.
396+
if ($Schema.type) {
397+
switch ($Schema.type) {
398+
'string' {
399+
if ($Value -isnot [string]) {
400+
$localErrors.Add("Field '$Path' must be a string")
401+
return $localErrors.ToArray()
402+
}
403+
404+
if ($Schema.pattern -and $Value -notmatch $Schema.pattern) {
405+
$localErrors.Add("Field '$Path' does not match required pattern: $($Schema.pattern)")
406+
}
407+
408+
if ($Schema.minLength -and $Value.Length -lt $Schema.minLength) {
409+
$localErrors.Add("Field '$Path' must have minimum length of $($Schema.minLength)")
410+
}
411+
}
412+
'boolean' {
413+
if ($Value -isnot [bool] -and $Value -notin @('true', 'false', 'True', 'False')) {
414+
$localErrors.Add("Field '$Path' must be a boolean")
415+
}
416+
}
417+
'array' {
418+
# Exclude strings from IEnumerable check - strings implement IEnumerable but aren't arrays.
419+
# Also exclude dictionaries/hashtables: they are IEnumerable, but semantically map to objects, not arrays.
420+
if (
421+
$Value -is [string] -or
422+
$Value -is [System.Collections.IDictionary] -or
423+
($Value -isnot [array] -and $Value -isnot [System.Collections.IEnumerable])
424+
) {
425+
$localErrors.Add("Field '$Path' must be an array")
426+
return $localErrors.ToArray()
427+
}
428+
429+
if ($Schema.items) {
430+
$i = 0
431+
foreach ($item in $Value) {
432+
$itemErrors = Test-ValueAgainstSchema -Value $item -Schema $Schema.items -Path "$Path[$i]"
433+
foreach ($e in $itemErrors) { $localErrors.Add($e) }
434+
$i++
435+
}
436+
}
437+
}
438+
'object' {
439+
$obj = $Value
440+
if ($obj -is [pscustomobject] -or $obj -is [hashtable]) {
441+
$obj = ConvertTo-HashTable -InputObject $obj
442+
}
443+
else {
444+
$localErrors.Add("Field '$Path' must be an object")
445+
return $localErrors.ToArray()
446+
}
447+
448+
if ($Schema.required) {
449+
foreach ($req in $Schema.required) {
450+
if (-not $obj.ContainsKey($req)) {
451+
$localErrors.Add("Missing required field: $Path.$req")
452+
}
453+
}
454+
}
455+
456+
if ($Schema.properties) {
457+
foreach ($p in $Schema.properties.PSObject.Properties) {
458+
$propName = $p.Name
459+
$propSchema = $p.Value
460+
if ($obj.ContainsKey($propName)) {
461+
$propErrors = Test-ValueAgainstSchema -Value $obj[$propName] -Schema $propSchema -Path "$Path.$propName"
462+
foreach ($e in $propErrors) { $localErrors.Add($e) }
463+
}
464+
}
465+
}
466+
}
467+
}
468+
}
469+
470+
# Enum validation.
471+
if ($Schema.enum) {
472+
if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) {
473+
foreach ($item in $Value) {
474+
if ($item -notin $Schema.enum) {
475+
$localErrors.Add("Field '$Path' contains invalid value: $item. Allowed: $($Schema.enum -join ', ')")
476+
}
477+
}
478+
}
479+
else {
480+
if ($Value -notin $Schema.enum) {
481+
$localErrors.Add("Field '$Path' must be one of: $($Schema.enum -join ', '). Got: $Value")
482+
}
483+
}
484+
}
485+
486+
return $localErrors.ToArray()
487+
}
488+
258489
function Test-JsonSchemaValidation {
259490
<#
260491
.SYNOPSIS
@@ -266,16 +497,19 @@ function Test-JsonSchemaValidation {
266497
pattern matching, enum values, and minimum length requirements.
267498
268499
Validation coverage:
269-
- required: Field presence validation
270-
- type: string, array, boolean type checking
500+
- required: Field presence validation (root + nested objects)
501+
- type: string, array, boolean, object type checking
502+
- properties: Nested object property validation
503+
- items: Array item validation
504+
- oneOf: Composition keyword support (exactly one subschema must match)
271505
- pattern: Regex pattern matching for strings
272506
- enum: Allowed value constraints
273507
- minLength: Minimum string length validation
274508
275509
Limitations (intentional for soft validation):
276510
- $ref: Schema references not resolved
277-
- allOf/anyOf/oneOf: Composition keywords not supported
278-
- object: Nested object validation not implemented
511+
- allOf/anyOf: Composition keywords not supported
512+
- additionalProperties: Not enforced
279513
280514
.PARAMETER Frontmatter
281515
Hashtable containing parsed frontmatter key-value pairs.
@@ -382,54 +616,8 @@ function Test-JsonSchemaValidation {
382616

383617
if ($Frontmatter.ContainsKey($fieldName)) {
384618
$value = $Frontmatter[$fieldName]
385-
386-
if ($fieldSchema.type) {
387-
switch ($fieldSchema.type) {
388-
'string' {
389-
if ($value -isnot [string]) {
390-
$errors.Add("Field '$fieldName' must be a string")
391-
}
392-
}
393-
'array' {
394-
# Exclude strings from IEnumerable check - strings implement IEnumerable but aren't arrays
395-
if ($value -is [string] -or ($value -isnot [array] -and $value -isnot [System.Collections.IEnumerable])) {
396-
$errors.Add("Field '$fieldName' must be an array")
397-
}
398-
}
399-
'boolean' {
400-
if ($value -isnot [bool] -and $value -notin @('true', 'false', 'True', 'False')) {
401-
$errors.Add("Field '$fieldName' must be a boolean")
402-
}
403-
}
404-
}
405-
}
406-
407-
if ($fieldSchema.pattern -and $value -is [string]) {
408-
if ($value -notmatch $fieldSchema.pattern) {
409-
$errors.Add("Field '$fieldName' does not match required pattern: $($fieldSchema.pattern)")
410-
}
411-
}
412-
413-
if ($fieldSchema.enum) {
414-
if ($value -is [array]) {
415-
foreach ($item in $value) {
416-
if ($item -notin $fieldSchema.enum) {
417-
$errors.Add("Field '$fieldName' contains invalid value: $item. Allowed: $($fieldSchema.enum -join ', ')")
418-
}
419-
}
420-
}
421-
else {
422-
if ($value -notin $fieldSchema.enum) {
423-
$errors.Add("Field '$fieldName' must be one of: $($fieldSchema.enum -join ', '). Got: $value")
424-
}
425-
}
426-
}
427-
428-
if ($fieldSchema.minLength -and $value -is [string]) {
429-
if ($value.Length -lt $fieldSchema.minLength) {
430-
$errors.Add("Field '$fieldName' must have minimum length of $($fieldSchema.minLength)")
431-
}
432-
}
619+
$validationErrors = Test-ValueAgainstSchema -Value $value -Schema $fieldSchema -Path $fieldName
620+
foreach ($e in $validationErrors) { $errors.Add($e) }
433621
}
434622
}
435623
}

0 commit comments

Comments
 (0)