microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/1637-b-tracking-paths

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/evals/Get-AgentDependencyMap.ps1

359lines · modecode

1#!/usr/bin/env pwsh
2# Copyright (c) Microsoft Corporation.
3# SPDX-License-Identifier: MIT
4#Requires -Version 7.0
5
6<#
7.SYNOPSIS
8 Build a JSON map of agent dependencies for the baseline-equivalence dispatcher.
9
10.DESCRIPTION
11 Walks `.github/agents/**/*.agent.md`, parses each agent's frontmatter and
12 body for declared and inline references to instructions, skills, and
13 subagents, and emits a deterministic JSON document at
14 `<OutputPath>` (default `<RepoRoot>/logs/agent-dependency-map.json`).
15
16 The JSON shape:
17 {
18 "<slug>": {
19 "agent": "<workspace-relative path>",
20 "instructions": [ "<path>", ... ],
21 "skills": [ "<path>", ... ],
22 "subagents": [ "<path>", ... ],
23 "warnings": [ "<message>", ... ]
24 },
25 ...
26 }
27
28 All sub-lists are workspace-relative paths, deduplicated, sorted.
29 Missing-reference warnings do not fail the script (exit 0). Cyclic
30 subagent chains are tolerated.
31
32.PARAMETER RepoRoot
33 Repository root. Defaults to `git rev-parse --show-toplevel`.
34
35.PARAMETER OutputPath
36 JSON output path. Defaults to `<RepoRoot>/logs/agent-dependency-map.json`.
37
38.EXAMPLE
39 pwsh scripts/evals/Get-AgentDependencyMap.ps1
40#>
41[CmdletBinding(SupportsShouldProcess)]
42[OutputType([string])]
43param(
44 [string]$RepoRoot,
45 [string]$OutputPath
46)
47
48Set-StrictMode -Version Latest
49$ErrorActionPreference = 'Stop'
50
51function Resolve-RepoRoot {
52 [CmdletBinding()]
53 [OutputType([string])]
54 param([string]$Override)
55
56 if ($Override) { return (Resolve-Path -LiteralPath $Override).Path }
57 try {
58 $root = (& git rev-parse --show-toplevel 2>$null).Trim()
59 if ($LASTEXITCODE -eq 0 -and $root) { return $root }
60 } catch {
61 Write-Verbose "git rev-parse failed: $($_.Exception.Message)"
62 }
63 return (Get-Location).Path
64}
65
66function ConvertTo-RelativePath {
67 [CmdletBinding()]
68 [OutputType([string])]
69 param(
70 [Parameter(Mandatory)] [string]$RepoRoot,
71 [Parameter(Mandatory)] [string]$Path
72 )
73
74 $rootFull = [System.IO.Path]::GetFullPath($RepoRoot)
75 $pathFull = [System.IO.Path]::GetFullPath($Path)
76 if ($pathFull.StartsWith($rootFull, [System.StringComparison]::OrdinalIgnoreCase)) {
77 $rel = $pathFull.Substring($rootFull.Length).TrimStart([char]'\', [char]'/')
78 return ($rel -replace '\\', '/')
79 }
80 return ($Path -replace '\\', '/')
81}
82
83function Read-AgentFile {
84 [CmdletBinding()]
85 [OutputType([hashtable])]
86 param([Parameter(Mandatory)] [string]$Path)
87
88 $raw = [System.IO.File]::ReadAllText($Path)
89 $frontmatter = ''
90 $body = $raw
91 if ($raw -match '(?s)^---\s*\r?\n(.*?)\r?\n---\s*\r?\n(.*)$') {
92 $frontmatter = $matches[1]
93 $body = $matches[2]
94 }
95 return @{ Frontmatter = $frontmatter; Body = $body }
96}
97
98function Get-FrontmatterListField {
99 [CmdletBinding()]
100 [OutputType([string[]])]
101 param(
102 [Parameter(Mandatory)] [string]$Frontmatter,
103 [Parameter(Mandatory)] [string]$Field
104 )
105
106 $results = New-Object System.Collections.Generic.List[string]
107 $lines = $Frontmatter -split "`r?`n"
108 $inList = $false
109 foreach ($line in $lines) {
110 if (-not $inList) {
111 if ($line -match "^$Field\s*:\s*\[(.*)\]\s*$") {
112 # Flow style: field: [a, b, c]
113 $items = $matches[1] -split ','
114 foreach ($item in $items) {
115 $t = $item.Trim().Trim('"').Trim("'")
116 if ($t) { $results.Add($t) }
117 }
118 return $results.ToArray()
119 }
120 if ($line -match "^$Field\s*:\s*$") {
121 $inList = $true
122 continue
123 }
124 } else {
125 if ($line -match '^\s*-\s*(.+?)\s*$') {
126 $results.Add($matches[1].Trim().Trim('"').Trim("'"))
127 } elseif ($line -match '^\S') {
128 # Next top-level key; stop.
129 break
130 }
131 }
132 }
133 return $results.ToArray()
134}
135
136function Find-ReferenceMatches {
137 [CmdletBinding()]
138 [OutputType([string[]])]
139 param([Parameter(Mandatory)] [string]$Body)
140
141 $hits = New-Object System.Collections.Generic.List[string]
142 # #file:<path> directives
143 foreach ($m in [regex]::Matches($Body, '#file:([^\s\)`]+)')) {
144 $hits.Add($m.Groups[1].Value)
145 }
146 # Markdown links into .github/{instructions,skills,agents}/
147 foreach ($m in [regex]::Matches($Body, '\]\(([^)]*\.github/(?:instructions|skills|agents)/[^)]+)\)')) {
148 $hits.Add($m.Groups[1].Value)
149 }
150 # Markdown links targeting any *.agent.md, *.instructions.md, or SKILL.md (covers `../../skills/...` relative links).
151 foreach ($m in [regex]::Matches($Body, '\]\(([^)]+(?:\.agent\.md|\.instructions\.md|/SKILL\.md))\)')) {
152 $hits.Add($m.Groups[1].Value)
153 }
154 # Bare path mentions of .github/{instructions,skills}/...md or .github/agents/...agent.md
155 foreach ($m in [regex]::Matches($Body, '\.github/(?:instructions|skills|agents)/[A-Za-z0-9_./*-]+\.(?:md|agent\.md|instructions\.md)')) {
156 $hits.Add($m.Value)
157 }
158 # Bare mentions of skill subpaths (e.g. `.github/skills/jira/jira/scripts/jira.py`) → resolve to SKILL.md anchor.
159 foreach ($m in [regex]::Matches($Body, '\.github/skills/([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)/')) {
160 $hits.Add(".github/skills/$($m.Groups[1].Value)/$($m.Groups[2].Value)/SKILL.md")
161 }
162 return $hits.ToArray()
163}
164
165function Resolve-RefToFiles {
166 [CmdletBinding()]
167 [OutputType([string[]])]
168 param(
169 [Parameter(Mandatory)] [string]$RepoRoot,
170 [Parameter(Mandatory)] [string]$Ref,
171 [string]$SourceDir
172 )
173
174 $normalized = $Ref -replace '\\', '/'
175 # Strip a single leading './' but preserve other leading dots (e.g. `.github/...`).
176 if ($normalized.StartsWith('./')) { $normalized = $normalized.Substring(2) }
177 # Strip a leading absolute slash (treat as repo-root relative).
178 $normalized = $normalized.TrimStart('/')
179 # Drop a trailing punctuation char (sentence-end periods leaking into refs).
180 $normalized = $normalized -replace '[.,;:)\]]+$', ''
181
182 # Candidate bases: explicit source dir first (for `../../...` style refs), then repo root.
183 $bases = New-Object System.Collections.Generic.List[string]
184 if ($normalized.StartsWith('../') -or $normalized.StartsWith('./')) {
185 if ($SourceDir) { $bases.Add($SourceDir) }
186 $bases.Add($RepoRoot)
187 } else {
188 $bases.Add($RepoRoot)
189 if ($SourceDir) { $bases.Add($SourceDir) }
190 }
191
192 # Glob expansion via Get-ChildItem when wildcards present
193 if ($normalized.Contains('*')) {
194 foreach ($base in $bases) {
195 # Handle `**` (recursive any-dir) by splitting on `/**/` and using -Recurse from the prefix.
196 if ($normalized -match '^(?<prefix>[^*]+)/\*\*/(?<leaf>.+)$') {
197 $prefix = Join-Path $base $matches.prefix
198 $leaf = $matches.leaf
199 $found = @(Get-ChildItem -Path $prefix -Recurse -Filter $leaf -ErrorAction SilentlyContinue -File)
200 } else {
201 $globPath = Join-Path $base $normalized
202 $found = @(Get-ChildItem -Path $globPath -Recurse -ErrorAction SilentlyContinue -File)
203 }
204 if ($found.Count -gt 0) {
205 return ,@($found | ForEach-Object { ConvertTo-RelativePath -RepoRoot $RepoRoot -Path $_.FullName })
206 }
207 }
208 return ,@()
209 }
210
211 foreach ($base in $bases) {
212 $full = Join-Path $base $normalized
213 try { $full = [System.IO.Path]::GetFullPath($full) } catch { continue }
214 if (Test-Path -LiteralPath $full -PathType Leaf) {
215 return ,@(ConvertTo-RelativePath -RepoRoot $RepoRoot -Path $full)
216 }
217 }
218 return ,@()
219}
220
221function Get-AgentSlug {
222 [CmdletBinding()]
223 [OutputType([string])]
224 param([Parameter(Mandatory)] [string]$AgentPath)
225 $leaf = Split-Path -Leaf $AgentPath
226 return ($leaf -replace '\.agent\.md$', '')
227}
228
229function ConvertTo-DeterministicJson {
230 [CmdletBinding()]
231 [OutputType([string])]
232 param([Parameter(Mandatory)] [hashtable]$Map)
233
234 $sortedKeys = @($Map.Keys | Sort-Object)
235 $sb = [System.Text.StringBuilder]::new()
236 [void]$sb.AppendLine('{')
237 for ($i = 0; $i -lt $sortedKeys.Count; $i++) {
238 $key = $sortedKeys[$i]
239 $record = $Map[$key]
240 [void]$sb.AppendLine(" $(ConvertTo-Json $key -Compress): {")
241 $fields = @('agent', 'instructions', 'skills', 'subagents', 'warnings')
242 for ($j = 0; $j -lt $fields.Count; $j++) {
243 $f = $fields[$j]
244 $value = $record[$f]
245 $jsonValue = if ($value -is [string]) {
246 ConvertTo-Json $value -Compress
247 } else {
248 # Sorted, deduplicated array
249 $arr = @($value | Sort-Object -Unique)
250 if ($arr.Count -eq 0) {
251 '[]'
252 } else {
253 "[`n " + (($arr | ForEach-Object { ConvertTo-Json $_ -Compress }) -join ",`n ") + "`n ]"
254 }
255 }
256 $sep = if ($j -lt ($fields.Count - 1)) { ',' } else { '' }
257 [void]$sb.AppendLine(" `"$f`": $jsonValue$sep")
258 }
259 $sep = if ($i -lt ($sortedKeys.Count - 1)) { ',' } else { '' }
260 [void]$sb.AppendLine(" }$sep")
261 }
262 [void]$sb.Append('}')
263 return $sb.ToString() + "`n"
264}
265
266# --- Main ---
267$resolvedRoot = Resolve-RepoRoot -Override $RepoRoot
268if (-not $OutputPath) {
269 $OutputPath = Join-Path $resolvedRoot 'logs/agent-dependency-map.json'
270}
271
272$agentsRoot = Join-Path $resolvedRoot '.github/agents'
273if (-not (Test-Path -LiteralPath $agentsRoot)) {
274 throw "Agents directory not found at '$agentsRoot'."
275}
276
277$agentFiles = @(Get-ChildItem -Path $agentsRoot -Recurse -Filter '*.agent.md' -File)
278$map = @{}
279
280foreach ($file in $agentFiles) {
281 $slug = Get-AgentSlug -AgentPath $file.Name
282 $parsed = Read-AgentFile -Path $file.FullName
283
284 $instructions = New-Object System.Collections.Generic.HashSet[string]
285 $skills = New-Object System.Collections.Generic.HashSet[string]
286 $subagents = New-Object System.Collections.Generic.HashSet[string]
287 $warnings = New-Object System.Collections.Generic.List[string]
288
289 $sourceDir = Split-Path -Parent $file.FullName
290
291 # Frontmatter list fields
292 foreach ($ref in (Get-FrontmatterListField -Frontmatter $parsed.Frontmatter -Field 'instructions')) {
293 $resolved = Resolve-RefToFiles -RepoRoot $resolvedRoot -Ref $ref -SourceDir $sourceDir
294 if ($resolved.Count -eq 0) { $warnings.Add("instructions ref not resolved: $ref") }
295 foreach ($r in $resolved) { [void]$instructions.Add($r) }
296 }
297 foreach ($ref in (Get-FrontmatterListField -Frontmatter $parsed.Frontmatter -Field 'skills')) {
298 $resolved = Resolve-RefToFiles -RepoRoot $resolvedRoot -Ref $ref -SourceDir $sourceDir
299 if ($resolved.Count -eq 0) { $warnings.Add("skills ref not resolved: $ref") }
300 foreach ($r in $resolved) { [void]$skills.Add($r) }
301 }
302 foreach ($ref in (Get-FrontmatterListField -Frontmatter $parsed.Frontmatter -Field 'agents')) {
303 # Frontmatter `agents:` lists by display name (e.g., "Researcher Subagent"); skip path resolution.
304 $warnings.Add("agents frontmatter entry recorded by name only: $ref")
305 }
306
307 # Body references
308 foreach ($ref in (Find-ReferenceMatches -Body $parsed.Body)) {
309 $resolved = Resolve-RefToFiles -RepoRoot $resolvedRoot -Ref $ref -SourceDir $sourceDir
310 if ($resolved.Count -eq 0) {
311 $warnings.Add("body ref not resolved: $ref")
312 continue
313 }
314 foreach ($r in $resolved) {
315 if ($r -like '*.agent.md') {
316 if ((ConvertTo-RelativePath -RepoRoot $resolvedRoot -Path $file.FullName) -ne $r) {
317 [void]$subagents.Add($r)
318 }
319 } elseif ($r -like '*.instructions.md') {
320 [void]$instructions.Add($r)
321 } elseif ($r -like '*/.github/skills/*' -or $r -like '.github/skills/*') {
322 [void]$skills.Add($r)
323 } elseif ($r -like '*/instructions/*' -or $r -like '*instructions*') {
324 [void]$instructions.Add($r)
325 } elseif ($r -like '*/skills/*') {
326 [void]$skills.Add($r)
327 }
328 }
329 }
330
331 $map[$slug] = @{
332 agent = ConvertTo-RelativePath -RepoRoot $resolvedRoot -Path $file.FullName
333 instructions = @($instructions)
334 skills = @($skills)
335 subagents = @($subagents)
336 warnings = @($warnings)
337 }
338}
339
340foreach ($w in ($map.Values.warnings | Where-Object { $_ })) {
341 Write-Warning $w
342}
343
344$json = ConvertTo-DeterministicJson -Map $map
345
346$outDir = Split-Path -Parent $OutputPath
347if (-not (Test-Path -LiteralPath $outDir)) {
348 if ($PSCmdlet.ShouldProcess($outDir, 'Create directory')) {
349 New-Item -ItemType Directory -Path $outDir -Force | Out-Null
350 }
351}
352
353if ($PSCmdlet.ShouldProcess($OutputPath, 'Write agent dependency map')) {
354 # Write with LF line endings.
355 [System.IO.File]::WriteAllText($OutputPath, ($json -replace "`r`n", "`n"))
356 Write-Host "wrote: $OutputPath ($($map.Count) agents)"
357}
358
359return $OutputPath