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/linting/Modules/AdrConsistency.psm1

946lines · modecode

1# Copyright (c) Microsoft Corporation.
2# SPDX-License-Identifier: MIT
3
4# AdrConsistency.psm1
5#
6# Purpose: ADR Planner Govern-phase consistency validator.
7# Enforces the rule registry under scripts/linting/rules/adr-consistency-rules.json
8# (ADR-CONSISTENCY-001 .. 009) against rendered ADR markdown files.
9# Author: HVE Core Team
10
11#Requires -Version 7.0
12#Requires -Modules @{ ModuleName = 'PowerShell-Yaml'; RequiredVersion = '0.4.7' }
13
14#region Module setup
15
16if (-not (Get-Module -Name PowerShell-Yaml)) {
17 try {
18 Import-Module PowerShell-Yaml -ErrorAction Stop
19 }
20 catch {
21 throw "PowerShell-Yaml module (RequiredVersion 0.4.7) is required by AdrConsistency.psm1. Install via: Install-Module -Name PowerShell-Yaml -RequiredVersion 0.4.7 -Scope CurrentUser. Inner error: $($_.Exception.Message)"
22 }
23}
24
25Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'AdrBodyParser.psm1') -Force
26
27$script:RuleRegistryPath = Join-Path -Path $PSScriptRoot -ChildPath '../rules/adr-consistency-rules.json'
28$script:RuleRegistry = @{}
29try {
30 $registryRaw = Get-Content -Path $script:RuleRegistryPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
31 foreach ($rule in $registryRaw.rules) {
32 $script:RuleRegistry[$rule.id] = $rule
33 }
34}
35catch {
36 throw "Failed to load ADR consistency rule registry from $($script:RuleRegistryPath): $($_.Exception.Message)"
37}
38
39$script:CanonicalPlanners = @('Security Planner', 'RAI Planner', 'SSSC Planner', 'ADR Planner')
40$script:UpstreamLabels = @('BRD', 'PRD', 'RPI')
41
42#endregion Module setup
43
44#region Helpers
45
46function Resolve-AdrTitleCase {
47 <#
48 .SYNOPSIS
49 Returns the input text with each word's first letter capitalized.
50 .DESCRIPTION
51 Normalizes case-insensitive regex captures to a stable form before
52 comparing against the canonical peer-planner set.
53 .PARAMETER Text
54 Input text.
55 .OUTPUTS
56 [string] Title-cased string in the invariant culture.
57 .EXAMPLE
58 Resolve-AdrTitleCase -Text 'security planner'
59 #>
60 [CmdletBinding()]
61 [OutputType([string])]
62 param(
63 [Parameter(Mandatory = $true)]
64 [AllowEmptyString()]
65 [string]$Text
66 )
67
68 if ([string]::IsNullOrEmpty($Text)) { return $Text }
69 $culture = [System.Globalization.CultureInfo]::InvariantCulture
70 return $culture.TextInfo.ToTitleCase($Text.ToLowerInvariant())
71}
72
73function Get-AdrFrontmatterAndBody {
74 <#
75 .SYNOPSIS
76 Splits ADR markdown into YAML frontmatter and body text.
77 .DESCRIPTION
78 Detects a leading '---' fenced YAML block and parses it via
79 ConvertFrom-Yaml. Returns the parsed frontmatter (or $null on parse
80 failure) alongside the remaining body markdown.
81 .PARAMETER Content
82 Raw ADR file content.
83 .OUTPUTS
84 [pscustomobject] with Frontmatter (parsed object or $null), Body (string),
85 and BodyStartLine (1-based file line where the body begins).
86 .EXAMPLE
87 Get-AdrFrontmatterAndBody -Content (Get-Content adr.md -Raw)
88 #>
89 [CmdletBinding()]
90 [OutputType([pscustomobject])]
91 param(
92 [Parameter(Mandatory = $true)]
93 [string]$Content
94 )
95
96 $frontmatter = $null
97 $body = $Content
98 $bodyStartLine = 1
99 if ($Content -match '(?s)^---\s*\r?\n(.*?)\r?\n---\r?\n?(.*)$') {
100 $yamlBlock = $Matches[1]
101 $body = $Matches[2]
102 $preambleLength = $Content.Length - $body.Length
103 if ($preambleLength -gt 0) {
104 $preamble = $Content.Substring(0, $preambleLength)
105 $bodyStartLine = ([regex]::Matches($preamble, "`n")).Count + 1
106 }
107 try {
108 $frontmatter = ConvertFrom-Yaml -Yaml $yamlBlock
109 }
110 catch {
111 $frontmatter = $null
112 }
113 }
114
115 return [pscustomobject]@{
116 Frontmatter = $frontmatter
117 Body = $body
118 BodyStartLine = $bodyStartLine
119 }
120}
121
122function Get-AdrFileLine {
123 <#
124 .SYNOPSIS
125 Maps a character offset within body text to a 1-based file line number.
126 .PARAMETER Text
127 Body text the offset refers to.
128 .PARAMETER Offset
129 Zero-based character offset within Text.
130 .PARAMETER BodyStartLine
131 1-based file line where Text begins.
132 .OUTPUTS
133 [object] File line number, or $null when Offset is out of range.
134 #>
135 [CmdletBinding()]
136 [OutputType([object])]
137 param(
138 [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Text,
139 [Parameter(Mandatory = $true)] [int]$Offset,
140 [int]$BodyStartLine = 1
141 )
142
143 if ($Offset -lt 0 -or $Offset -gt $Text.Length) { return $null }
144 $prefix = $Text.Substring(0, $Offset)
145 return $BodyStartLine + ([regex]::Matches($prefix, "`n")).Count
146}
147
148function Find-AdrTextLine {
149 <#
150 .SYNOPSIS
151 Resolves the file line of the first case-insensitive occurrence of a string.
152 .PARAMETER RawBody
153 Raw ADR body markdown.
154 .PARAMETER Search
155 Literal text to locate.
156 .PARAMETER BodyStartLine
157 1-based file line where RawBody begins.
158 .OUTPUTS
159 [object] File line number, or $null when not found.
160 #>
161 [CmdletBinding()]
162 [OutputType([object])]
163 param(
164 [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$RawBody,
165 [string]$Search,
166 [int]$BodyStartLine = 1
167 )
168
169 if ([string]::IsNullOrEmpty($Search)) { return $null }
170 $index = $RawBody.IndexOf($Search, [System.StringComparison]::OrdinalIgnoreCase)
171 if ($index -lt 0) { return $null }
172 return Get-AdrFileLine -Text $RawBody -Offset $index -BodyStartLine $BodyStartLine
173}
174
175function Find-AdrHeadingLine {
176 <#
177 .SYNOPSIS
178 Resolves the file line of an H2 or H3 heading by its text.
179 .PARAMETER RawBody
180 Raw ADR body markdown.
181 .PARAMETER Heading
182 Heading text without leading '#' markers.
183 .PARAMETER BodyStartLine
184 1-based file line where RawBody begins.
185 .OUTPUTS
186 [object] File line number, or $null when the heading is absent.
187 #>
188 [CmdletBinding()]
189 [OutputType([object])]
190 param(
191 [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$RawBody,
192 [Parameter(Mandatory = $true)] [string]$Heading,
193 [int]$BodyStartLine = 1
194 )
195
196 $escaped = [regex]::Escape($Heading)
197 $match = [regex]::Match($RawBody, '(?im)^\s*#{2,3}\s+' + $escaped + '\s*$')
198 if (-not $match.Success) { return $null }
199 return Get-AdrFileLine -Text $RawBody -Offset $match.Index -BodyStartLine $BodyStartLine
200}
201
202function Get-AdrRawH2Section {
203 <#
204 .SYNOPSIS
205 Returns the raw text of an H2 section located by heading text.
206 .DESCRIPTION
207 Locates an H2 heading by exact text and returns the section body up
208 to the next H2 heading or end of input.
209 .PARAMETER Body
210 ADR body markdown.
211 .PARAMETER Heading
212 Plain heading text without the '## ' prefix.
213 .OUTPUTS
214 [string] Section text or empty string when missing.
215 .EXAMPLE
216 Get-AdrRawH2Section -Body $body -Heading 'More Information'
217 #>
218 [CmdletBinding()]
219 [OutputType([string])]
220 param(
221 [Parameter(Mandatory = $true)]
222 [string]$Body,
223
224 [Parameter(Mandatory = $true)]
225 [string]$Heading
226 )
227
228 $escaped = [regex]::Escape($Heading)
229 $pattern = '(?ims)^\s*##\s+' + $escaped + '\s*$(.*?)(?=^\s*##\s+\S|\z)'
230 if ($Body -match $pattern) {
231 return $Matches[1]
232 }
233 return ''
234}
235
236function Get-AdrDescriptionText {
237 <#
238 .SYNOPSIS
239 Returns the body text preceding the first H2 section.
240 .DESCRIPTION
241 Captures the description prologue between the title and the first H2
242 heading. Returns the full body when no H2 heading is present.
243 .PARAMETER Body
244 ADR body markdown.
245 .OUTPUTS
246 [string] Description prologue text.
247 .EXAMPLE
248 Get-AdrDescriptionText -Body $rawBody
249 #>
250 [CmdletBinding()]
251 [OutputType([string])]
252 param(
253 [Parameter(Mandatory = $true)]
254 [string]$Body
255 )
256
257 if ($Body -match '(?sm)\A(.*?)(?=^\s*##\s+\S)') {
258 return $Matches[1]
259 }
260 return $Body
261}
262
263function Format-AdrList {
264 <#
265 .SYNOPSIS
266 Formats a string array as a single-quoted, comma-separated list.
267 .DESCRIPTION
268 Renders lists for inclusion in violation messages. Returns '(none)'
269 for null or empty input.
270 .PARAMETER Items
271 String items to format.
272 .OUTPUTS
273 [string] Formatted list.
274 .EXAMPLE
275 Format-AdrList -Items @('foo', 'bar')
276 #>
277 [CmdletBinding()]
278 [OutputType([string])]
279 param(
280 [string[]]$Items
281 )
282
283 if ($null -eq $Items -or $Items.Count -eq 0) {
284 return '(none)'
285 }
286 return ($Items | ForEach-Object { "'$_'" }) -join ', '
287}
288
289function New-AdrViolation {
290 <#
291 .SYNOPSIS
292 Builds a violation object for a registered ADR consistency rule.
293 .DESCRIPTION
294 Looks up the rule from the in-memory registry, applies replacement
295 tokens to the message template, and returns a structured record.
296 .PARAMETER RuleId
297 Stable rule identifier (ADR-CONSISTENCY-NNN).
298 .PARAMETER FilePath
299 ADR file the violation applies to.
300 .PARAMETER Replacements
301 Hashtable of token names to substitution values.
302 .PARAMETER Line
303 Optional line number associated with the violation.
304 .OUTPUTS
305 [pscustomobject] Violation record with file, ruleId, severity, message, line.
306 .EXAMPLE
307 New-AdrViolation -RuleId 'ADR-CONSISTENCY-001' -FilePath $path -Replacements @{ frontmatter_only = '...' }
308 #>
309 [CmdletBinding()]
310 [OutputType([pscustomobject])]
311 param(
312 [Parameter(Mandatory = $true)]
313 [string]$RuleId,
314
315 [Parameter(Mandatory = $true)]
316 [string]$FilePath,
317
318 [Parameter(Mandatory = $false)]
319 [hashtable]$Replacements = @{},
320
321 [Parameter(Mandatory = $false)]
322 [Nullable[int]]$Line
323 )
324
325 $rule = $script:RuleRegistry[$RuleId]
326 if (-not $rule) {
327 throw "Unknown rule id: $RuleId"
328 }
329
330 $message = $rule.message
331 foreach ($key in $Replacements.Keys) {
332 $token = '{' + $key + '}'
333 $message = $message.Replace($token, [string]$Replacements[$key])
334 }
335
336 return [pscustomobject]@{
337 file = $FilePath
338 ruleId = $RuleId
339 severity = $rule.severity
340 message = $message
341 line = $Line
342 }
343}
344
345function ConvertTo-AdrNormalizedText {
346 <#
347 .SYNOPSIS
348 Normalizes text for set-equality comparisons.
349 .DESCRIPTION
350 Trims, lowercases, collapses internal whitespace, and strips trailing
351 sentence punctuation so equivalent items compare equal.
352 .PARAMETER Text
353 Input text.
354 .OUTPUTS
355 [string] Normalized form, or empty string for null/whitespace input.
356 .EXAMPLE
357 ConvertTo-AdrNormalizedText -Text ' Foo Bar. '
358 #>
359 [CmdletBinding()]
360 [OutputType([string])]
361 param(
362 [string]$Text
363 )
364
365 if ([string]::IsNullOrWhiteSpace($Text)) { return '' }
366 $normalized = $Text.Trim().ToLowerInvariant()
367 $normalized = [regex]::Replace($normalized, '\s+', ' ')
368 $normalized = $normalized.TrimEnd('.', '!', '?', ',', ';', ':')
369 return $normalized
370}
371
372#endregion Helpers
373
374#region Rule checks
375
376function Test-AffectedComponentsMirror {
377 <#
378 .SYNOPSIS
379 ADR-CONSISTENCY-001: frontmatter and body Affected Components must agree.
380 .PARAMETER Frontmatter
381 Parsed frontmatter object.
382 .PARAMETER Body
383 Parsed body sections object from Get-AdrBodySections.
384 .PARAMETER FilePath
385 ADR file path used in violations.
386 .OUTPUTS
387 [object[]] Zero or one violation record.
388 #>
389 [CmdletBinding()]
390 [OutputType([object[]])]
391 param(
392 [Parameter(Mandatory = $true)] $Frontmatter,
393 [Parameter(Mandatory = $true)] $Body,
394 [Parameter(Mandatory = $true)] [string]$FilePath
395 )
396
397 $fmList = @()
398 if ($Frontmatter -and $Frontmatter.affected_components) {
399 $fmList = @($Frontmatter.affected_components | ForEach-Object { ConvertTo-AdrNormalizedText -Text $_ })
400 }
401 $bodyList = @($Body.AffectedComponents | ForEach-Object { ConvertTo-AdrNormalizedText -Text $_ })
402
403 $fmOnly = @($fmList | Where-Object { $_ -and $bodyList -notcontains $_ })
404 $bodyOnly = @($bodyList | Where-Object { $_ -and $fmList -notcontains $_ })
405
406 if ($fmOnly.Count -eq 0 -and $bodyOnly.Count -eq 0) { return @() }
407
408 return @(New-AdrViolation -RuleId 'ADR-CONSISTENCY-001' -FilePath $FilePath -Replacements @{
409 frontmatter_only = Format-AdrList -Items $fmOnly
410 body_only = Format-AdrList -Items $bodyOnly
411 })
412}
413
414function Test-SuccessCriteriaSourceResolves {
415 <#
416 .SYNOPSIS
417 ADR-CONSISTENCY-002: success_criteria[].source paths must resolve inside the repo.
418 .DESCRIPTION
419 Joins each path-shaped source against RepoRoot, normalizes the absolute
420 path, and verifies the resolved location both exists and falls inside
421 the repository tree. Paths that escape the repo via '..' segments or
422 absolute references outside RepoRoot raise the same violation as
423 missing files.
424 .PARAMETER Frontmatter
425 Parsed frontmatter object.
426 .PARAMETER Body
427 Parsed body sections object.
428 .PARAMETER FilePath
429 ADR file path used in violations.
430 .PARAMETER RepoRoot
431 Repository root used as the resolution base and containment boundary.
432 .OUTPUTS
433 [object[]] Zero or more violation records.
434 #>
435 [CmdletBinding()]
436 [OutputType([object[]])]
437 param(
438 [Parameter(Mandatory = $true)] $Frontmatter,
439 [Parameter(Mandatory = $true)] $Body,
440 [Parameter(Mandatory = $true)] [string]$FilePath,
441 [Parameter(Mandatory = $true)] [string]$RepoRoot
442 )
443
444 $violations = @()
445 if (-not $Frontmatter -or -not $Frontmatter.success_criteria) { return $violations }
446
447 $pathPattern = '^[A-Za-z0-9_\-./]+/[A-Za-z0-9_\-./]+\.[A-Za-z0-9]{1,8}$'
448 $repoRootAbsolute = [System.IO.Path]::GetFullPath($RepoRoot).TrimEnd(
449 [System.IO.Path]::DirectorySeparatorChar,
450 [System.IO.Path]::AltDirectorySeparatorChar
451 )
452 $boundary = $repoRootAbsolute + [System.IO.Path]::DirectorySeparatorChar
453
454 $criteria = @($Frontmatter.success_criteria)
455 for ($i = 0; $i -lt $criteria.Count; $i++) {
456 $entry = $criteria[$i]
457 if (-not $entry) { continue }
458 $source = $null
459 if ($entry -is [System.Collections.IDictionary]) {
460 $source = $entry['source']
461 }
462 elseif ($entry.PSObject.Properties['source']) {
463 $source = $entry.source
464 }
465 if ([string]::IsNullOrWhiteSpace($source)) { continue }
466 if ($source -notmatch $pathPattern) { continue }
467
468 $joined = Join-Path -Path $RepoRoot -ChildPath $source
469 $resolvedAbsolute = $null
470 try {
471 $resolvedAbsolute = [System.IO.Path]::GetFullPath($joined)
472 }
473 catch {
474 $resolvedAbsolute = $null
475 }
476
477 $insideRepo = $resolvedAbsolute -and (
478 $resolvedAbsolute -eq $repoRootAbsolute -or
479 $resolvedAbsolute.StartsWith($boundary, [System.StringComparison]::OrdinalIgnoreCase)
480 )
481
482 if (-not $insideRepo -or -not (Test-Path -LiteralPath $resolvedAbsolute)) {
483 $violations += New-AdrViolation -RuleId 'ADR-CONSISTENCY-002' -FilePath $FilePath -Replacements @{
484 index = $i
485 source = $source
486 }
487 }
488 }
489 return $violations
490}
491
492function Test-StatePlaceholderResolved {
493 <#
494 .SYNOPSIS
495 ADR-CONSISTENCY-003: unresolved {{state.*}} or pipe-separated enum placeholders.
496 .DESCRIPTION
497 Flags '{{state.<path>}}' tokens left in the body and any 'autonomyTier:'
498 line whose value is still a pipe-separated word list (for example
499 'autonomyTier: full|partial|manual' or 'autonomyTier: auto|semi'),
500 which indicates an unresolved enum placeholder.
501 .PARAMETER Frontmatter
502 Parsed frontmatter object.
503 .PARAMETER Body
504 Parsed body sections object.
505 .PARAMETER FilePath
506 ADR file path used in violations.
507 .PARAMETER RawBody
508 Raw ADR body markdown.
509 .OUTPUTS
510 [object[]] Zero or one violation record.
511 #>
512 [CmdletBinding()]
513 [OutputType([object[]])]
514 param(
515 [Parameter(Mandatory = $true)] $Frontmatter,
516 [Parameter(Mandatory = $true)] $Body,
517 [Parameter(Mandatory = $true)] [string]$FilePath,
518 [Parameter(Mandatory = $true)] [string]$RawBody,
519 [int]$BodyStartLine = 1
520 )
521
522 $tokens = @()
523 $stateMatches = [regex]::Matches($RawBody, '\{\{state\.[^}]+\}\}')
524 foreach ($m in $stateMatches) { $tokens += $m.Value }
525
526 # Catches any unresolved enum-style placeholder after autonomyTier:
527 $autonomyMatches = [regex]::Matches($RawBody, 'autonomyTier:\s*[A-Za-z]+\|[A-Za-z]+(\|[A-Za-z]+)*')
528 foreach ($m in $autonomyMatches) { $tokens += $m.Value }
529
530 if ($tokens.Count -eq 0) { return @() }
531
532 $offset = -1
533 if ($stateMatches.Count -gt 0) { $offset = $stateMatches[0].Index }
534 if ($autonomyMatches.Count -gt 0 -and ($offset -lt 0 -or $autonomyMatches[0].Index -lt $offset)) {
535 $offset = $autonomyMatches[0].Index
536 }
537 $line = if ($offset -ge 0) { Get-AdrFileLine -Text $RawBody -Offset $offset -BodyStartLine $BodyStartLine } else { $null }
538
539 $unique = @($tokens | Select-Object -Unique)
540 return @(New-AdrViolation -RuleId 'ADR-CONSISTENCY-003' -FilePath $FilePath -Line $line -Replacements @{
541 tokens = Format-AdrList -Items $unique
542 })
543}
544
545function Test-PeerPlannerNames {
546 <#
547 .SYNOPSIS
548 ADR-CONSISTENCY-004: only canonical peer-planner labels in description and drivers.
549 .DESCRIPTION
550 Strips fenced code blocks and inline code spans from the description
551 prologue and Decision Drivers, then scans case-insensitively for
552 '<word> Planner' labels. Matches whose word is BRD, PRD, or RPI are
553 normalized to uppercase and surfaced as upstream-artifact misuses;
554 all other matches are title-cased and compared against
555 $script:CanonicalPlanners.
556 .PARAMETER Frontmatter
557 Parsed frontmatter object.
558 .PARAMETER Body
559 Parsed body sections object.
560 .PARAMETER FilePath
561 ADR file path used in violations.
562 .PARAMETER RawBody
563 Raw ADR body markdown.
564 .OUTPUTS
565 [object[]] Zero or one violation record.
566 #>
567 [CmdletBinding()]
568 [OutputType([object[]])]
569 param(
570 [Parameter(Mandatory = $true)] $Frontmatter,
571 [Parameter(Mandatory = $true)] $Body,
572 [Parameter(Mandatory = $true)] [string]$FilePath,
573 [Parameter(Mandatory = $true)] [string]$RawBody,
574 [int]$BodyStartLine = 1
575 )
576
577 $description = Get-AdrDescriptionText -Body $RawBody
578 $driversText = ($Body.DecisionDrivers -join "`n")
579 $scanText = "$description`n$driversText"
580 $scanText = Remove-AdrFencedCodeBlocks -Text $scanText
581
582 $found = New-Object System.Collections.Generic.List[string]
583
584 foreach ($m in [regex]::Matches($scanText, '(?i)\b([A-Za-z]+)\s+Planner\b')) {
585 $rawWord = $m.Groups[1].Value
586 if ($script:UpstreamLabels -contains $rawWord.ToUpperInvariant()) { continue }
587 $normalized = Resolve-AdrTitleCase -Text "$rawWord Planner"
588 if ($script:CanonicalPlanners -notcontains $normalized) {
589 $null = $found.Add($normalized)
590 }
591 }
592 foreach ($m in [regex]::Matches($scanText, '(?i)\b(BRD|PRD|RPI)\s+Planner\b')) {
593 $null = $found.Add(($m.Groups[1].Value.ToUpperInvariant() + ' Planner'))
594 }
595
596 if ($found.Count -eq 0) { return @() }
597
598 $unique = @($found | Select-Object -Unique)
599 $line = Find-AdrTextLine -RawBody $RawBody -Search $unique[0] -BodyStartLine $BodyStartLine
600 if ($null -eq $line) {
601 $line = Find-AdrHeadingLine -RawBody $RawBody -Heading 'Decision Drivers' -BodyStartLine $BodyStartLine
602 }
603 return @(New-AdrViolation -RuleId 'ADR-CONSISTENCY-004' -FilePath $FilePath -Line $line -Replacements @{
604 labels = Format-AdrList -Items $unique
605 })
606}
607
608function Test-DriversMatrixCardinality {
609 <#
610 .SYNOPSIS
611 ADR-CONSISTENCY-005: Decision Drivers and Decision Outcome matrix must be 1:1.
612 .PARAMETER Frontmatter
613 Parsed frontmatter object.
614 .PARAMETER Body
615 Parsed body sections object.
616 .PARAMETER FilePath
617 ADR file path used in violations.
618 .OUTPUTS
619 [object[]] Zero or one violation record.
620 #>
621 [CmdletBinding()]
622 [OutputType([object[]])]
623 param(
624 [Parameter(Mandatory = $true)] $Frontmatter,
625 [Parameter(Mandatory = $true)] $Body,
626 [Parameter(Mandatory = $true)] [string]$FilePath
627 )
628
629 $drivers = @($Body.DecisionDrivers | ForEach-Object { ConvertTo-AdrNormalizedText -Text $_ } | Where-Object { $_ })
630 $matrix = @($Body.DecisionOutcomeMatrixDrivers | ForEach-Object { ConvertTo-AdrNormalizedText -Text $_ } | Where-Object { $_ })
631
632 $driversOnly = @($drivers | Where-Object { $matrix -notcontains $_ })
633 $matrixOnly = @($matrix | Where-Object { $drivers -notcontains $_ })
634
635 if ($driversOnly.Count -eq 0 -and $matrixOnly.Count -eq 0) { return @() }
636
637 return @(New-AdrViolation -RuleId 'ADR-CONSISTENCY-005' -FilePath $FilePath -Replacements @{
638 drivers_only = Format-AdrList -Items $driversOnly
639 matrix_only = Format-AdrList -Items $matrixOnly
640 })
641}
642
643function Test-RisksConsequencesPairing {
644 <#
645 .SYNOPSIS
646 ADR-CONSISTENCY-006: risk-shaped Bad consequences must be paired in Risks and Mitigations.
647 .DESCRIPTION
648 A bullet is treated as risk-shaped only when it begins with 'risk:'
649 or contains the word 'risk' alongside a probability/uncertainty modal
650 (may, could, might, likely, possible, possibility). Each risk-shaped
651 bullet is then matched (substring or equality) against the Risks and
652 Mitigations table.
653 .PARAMETER Frontmatter
654 Parsed frontmatter object.
655 .PARAMETER Body
656 Parsed body sections object.
657 .PARAMETER FilePath
658 ADR file path used in violations.
659 .OUTPUTS
660 [object[]] Zero or one violation record.
661 #>
662 [CmdletBinding()]
663 [OutputType([object[]])]
664 param(
665 [Parameter(Mandatory = $true)] $Frontmatter,
666 [Parameter(Mandatory = $true)] $Body,
667 [Parameter(Mandatory = $true)] [string]$FilePath,
668 [string]$RawBody = '',
669 [int]$BodyStartLine = 1
670 )
671
672 $bad = @($Body.BadConsequences)
673 if ($bad.Count -eq 0) { return @() }
674
675 $riskShaped = @($bad | Where-Object {
676 $_ -match '(?i)^\s*risk\s*:' -or
677 ($_ -match '(?i)\brisk\b' -and $_ -match '(?i)\b(may|could|might|likely|possible|possibility)\b')
678 })
679 if ($riskShaped.Count -eq 0) { return @() }
680
681 $risks = @($Body.RisksAndMitigationsRisks | ForEach-Object { ConvertTo-AdrNormalizedText -Text $_ } | Where-Object { $_ })
682 $unpaired = @()
683 foreach ($entry in $riskShaped) {
684 $needle = ConvertTo-AdrNormalizedText -Text ($entry -replace '(?i)^\s*risk\s*:\s*', '')
685 if ([string]::IsNullOrEmpty($needle)) { continue }
686 $paired = $false
687 foreach ($r in $risks) {
688 if ($r -eq $needle -or $r.Contains($needle) -or $needle.Contains($r)) {
689 $paired = $true
690 break
691 }
692 }
693 if (-not $paired) { $unpaired += $entry }
694 }
695
696 if ($unpaired.Count -eq 0) { return @() }
697
698 $line = $null
699 if (-not [string]::IsNullOrEmpty($RawBody)) {
700 $line = Find-AdrTextLine -RawBody $RawBody -Search $unpaired[0] -BodyStartLine $BodyStartLine
701 if ($null -eq $line) {
702 $line = Find-AdrHeadingLine -RawBody $RawBody -Heading 'Consequences' -BodyStartLine $BodyStartLine
703 }
704 }
705 return @(New-AdrViolation -RuleId 'ADR-CONSISTENCY-006' -FilePath $FilePath -Line $line -Replacements @{
706 unpaired = Format-AdrList -Items $unpaired
707 })
708}
709
710function Test-NumericClaimGeneralized {
711 <#
712 .SYNOPSIS
713 ADR-CONSISTENCY-007: warn on unverified numeric claims in narrative sections.
714 .DESCRIPTION
715 Strips fenced code blocks and inline code spans from each scanned
716 section before searching for '<number> <unit>' patterns. Sections
717 inspected are Confirmation, Bad Consequences, and More Information.
718 .PARAMETER Frontmatter
719 Parsed frontmatter object.
720 .PARAMETER Body
721 Parsed body sections object.
722 .PARAMETER FilePath
723 ADR file path used in violations.
724 .PARAMETER RawBody
725 Raw ADR body markdown.
726 .OUTPUTS
727 [object[]] Zero or more violation records (one per matched claim).
728 #>
729 [CmdletBinding()]
730 [OutputType([object[]])]
731 param(
732 [Parameter(Mandatory = $true)] $Frontmatter,
733 [Parameter(Mandatory = $true)] $Body,
734 [Parameter(Mandatory = $true)] [string]$FilePath,
735 [Parameter(Mandatory = $true)] [string]$RawBody,
736 [int]$BodyStartLine = 1
737 )
738
739 # Negative lookbehind skips version strings (e.g. 'v7.0', 'Version 7.0', '-Version 7.0')
740 # so directives like '#Requires -Version 7.0' are not flagged as unverified numeric claims.
741 $pattern = '(?<!(?i:v|ver\.?\s*|version\s*|-version\s*))\b(\d+(?:\.\d+)?)\s*(tests?|specs?|cases?|files?|lines?|hours?|days?|minutes?|seconds?|ms|users?|requests?|MB|GB|KB|%)\b'
742
743 $sections = @(
744 @{ Name = 'Confirmation'; Text = ($Body.Confirmation) },
745 @{ Name = 'Consequences'; Text = (($Body.BadConsequences) -join "`n") },
746 @{ Name = 'More Information'; Text = (Get-AdrRawH2Section -Body $RawBody -Heading 'More Information') }
747 )
748
749 $violations = @()
750 foreach ($section in $sections) {
751 $text = $section.Text
752 if ([string]::IsNullOrWhiteSpace($text)) { continue }
753 $text = Remove-AdrFencedCodeBlocks -Text $text
754 if ([string]::IsNullOrWhiteSpace($text)) { continue }
755 foreach ($m in [regex]::Matches($text, $pattern)) {
756 $claim = $m.Value
757 $line = Find-AdrTextLine -RawBody $RawBody -Search $claim -BodyStartLine $BodyStartLine
758 $violations += New-AdrViolation -RuleId 'ADR-CONSISTENCY-007' -FilePath $FilePath -Line $line -Replacements @{
759 claim = $claim
760 section = $section.Name
761 }
762 }
763 }
764 return $violations
765}
766
767function Test-DriverTriggerMapComplete {
768 <#
769 .SYNOPSIS
770 ADR-CONSISTENCY-008: every Decision Driver must key into driverToTriggerMap.
771 .PARAMETER Frontmatter
772 Parsed frontmatter object containing decisionMetadata.driverToTriggerMap.
773 .PARAMETER Body
774 Parsed body sections object.
775 .PARAMETER FilePath
776 ADR file path used in violations.
777 .OUTPUTS
778 [object[]] Zero or one violation record.
779 #>
780 [CmdletBinding()]
781 [OutputType([object[]])]
782 param(
783 [Parameter(Mandatory = $true)] $Frontmatter,
784 [Parameter(Mandatory = $true)] $Body,
785 [Parameter(Mandatory = $true)] [string]$FilePath
786 )
787
788 $drivers = @($Body.DecisionDrivers | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
789 if ($drivers.Count -eq 0) { return @() }
790
791 $mapKeys = @()
792 if ($Frontmatter -and $Frontmatter.decisionMetadata) {
793 $dm = $Frontmatter.decisionMetadata
794 $map = $null
795 if ($dm -is [System.Collections.IDictionary]) {
796 $map = $dm['driverToTriggerMap']
797 }
798 elseif ($dm.PSObject.Properties['driverToTriggerMap']) {
799 $map = $dm.driverToTriggerMap
800 }
801 if ($map) {
802 if ($map -is [System.Collections.IDictionary]) {
803 $mapKeys = @($map.Keys | ForEach-Object { ConvertTo-AdrNormalizedText -Text $_ })
804 }
805 else {
806 $mapKeys = @($map.PSObject.Properties.Name | ForEach-Object { ConvertTo-AdrNormalizedText -Text $_ })
807 }
808 }
809 }
810
811 $missing = @()
812 foreach ($d in $drivers) {
813 $needle = ConvertTo-AdrNormalizedText -Text $d
814 $found = $false
815 foreach ($k in $mapKeys) {
816 if ($k -eq $needle -or $k.Contains($needle) -or $needle.Contains($k)) {
817 $found = $true
818 break
819 }
820 }
821 if (-not $found) { $missing += $d }
822 }
823
824 if ($missing.Count -eq 0) { return @() }
825
826 return @(New-AdrViolation -RuleId 'ADR-CONSISTENCY-008' -FilePath $FilePath -Replacements @{
827 missing = Format-AdrList -Items $missing
828 })
829}
830
831function Test-AffectedComponentsCited {
832 <#
833 .SYNOPSIS
834 ADR-CONSISTENCY-009: every affected_components entry must be cited in body.
835 .DESCRIPTION
836 Compares the frontmatter affected_components list against path tokens
837 extracted from Context and More Information sections, treating filename
838 equality and trailing-segment matches as citations.
839 .PARAMETER Frontmatter
840 Parsed frontmatter object.
841 .PARAMETER Body
842 Parsed body sections object.
843 .PARAMETER FilePath
844 ADR file path used in violations.
845 .OUTPUTS
846 [object[]] Zero or one violation record.
847 #>
848 [CmdletBinding()]
849 [OutputType([object[]])]
850 param(
851 [Parameter(Mandatory = $true)] $Frontmatter,
852 [Parameter(Mandatory = $true)] $Body,
853 [Parameter(Mandatory = $true)] [string]$FilePath
854 )
855
856 if (-not $Frontmatter -or -not $Frontmatter.affected_components) { return @() }
857 $fmList = @($Frontmatter.affected_components | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
858 if ($fmList.Count -eq 0) { return @() }
859
860 $citationTokens = @()
861 $citationTokens += @($Body.ContextPathTokens)
862 $citationTokens += @($Body.MoreInformationPathTokens)
863 $citationTokens = @($citationTokens | Where-Object { $_ })
864
865 $uncited = @()
866 foreach ($entry in $fmList) {
867 $entryNorm = $entry.Trim()
868 $base = [System.IO.Path]::GetFileName($entryNorm)
869 $cited = $false
870 foreach ($tok in $citationTokens) {
871 if ($tok -eq $entryNorm -or $tok.EndsWith('/' + $entryNorm) -or [System.IO.Path]::GetFileName($tok) -eq $base) {
872 $cited = $true
873 break
874 }
875 }
876 if (-not $cited) { $uncited += $entry }
877 }
878
879 if ($uncited.Count -eq 0) { return @() }
880
881 return @(New-AdrViolation -RuleId 'ADR-CONSISTENCY-009' -FilePath $FilePath -Replacements @{
882 uncited = Format-AdrList -Items $uncited
883 })
884}
885
886#endregion Rule checks
887
888#region Entry function
889
890function Invoke-AdrConsistencyValidation {
891 <#
892 .SYNOPSIS
893 Runs every ADR consistency rule against a single ADR markdown file.
894 .DESCRIPTION
895 Reads the ADR, splits frontmatter from body, parses body sections via
896 AdrBodyParser, dispatches all Test-* rule functions, and returns the
897 aggregated violations.
898 .PARAMETER Path
899 Absolute or repo-relative path to the ADR markdown file.
900 .PARAMETER RepoRoot
901 Repository root used as the path-resolution and containment boundary
902 for path-shaped sources.
903 .OUTPUTS
904 [pscustomobject] with File and Violations properties.
905 .EXAMPLE
906 Invoke-AdrConsistencyValidation -Path docs/planning/adrs/0001.md -RepoRoot $repoRoot
907 #>
908 [CmdletBinding()]
909 [OutputType([pscustomobject])]
910 param(
911 [Parameter(Mandatory = $true)]
912 [string]$Path,
913
914 [Parameter(Mandatory = $true)]
915 [string]$RepoRoot
916 )
917
918 if (-not (Test-Path -LiteralPath $Path)) {
919 throw "ADR file not found: $Path"
920 }
921
922 $content = Get-Content -LiteralPath $Path -Raw
923 $split = Get-AdrFrontmatterAndBody -Content $content
924 $body = Get-AdrBodySections -Text $split.Body
925 $rawBody = $split.Body
926
927 $violations = @()
928 $violations += Test-AffectedComponentsMirror -Frontmatter $split.Frontmatter -Body $body -FilePath $Path
929 $violations += Test-SuccessCriteriaSourceResolves -Frontmatter $split.Frontmatter -Body $body -FilePath $Path -RepoRoot $RepoRoot
930 $violations += Test-StatePlaceholderResolved -Frontmatter $split.Frontmatter -Body $body -FilePath $Path -RawBody $rawBody -BodyStartLine $split.BodyStartLine
931 $violations += Test-PeerPlannerNames -Frontmatter $split.Frontmatter -Body $body -FilePath $Path -RawBody $rawBody -BodyStartLine $split.BodyStartLine
932 $violations += Test-DriversMatrixCardinality -Frontmatter $split.Frontmatter -Body $body -FilePath $Path
933 $violations += Test-RisksConsequencesPairing -Frontmatter $split.Frontmatter -Body $body -FilePath $Path -RawBody $rawBody -BodyStartLine $split.BodyStartLine
934 $violations += Test-NumericClaimGeneralized -Frontmatter $split.Frontmatter -Body $body -FilePath $Path -RawBody $rawBody -BodyStartLine $split.BodyStartLine
935 $violations += Test-DriverTriggerMapComplete -Frontmatter $split.Frontmatter -Body $body -FilePath $Path
936 $violations += Test-AffectedComponentsCited -Frontmatter $split.Frontmatter -Body $body -FilePath $Path
937
938 return [pscustomobject]@{
939 File = $Path
940 Violations = @($violations)
941 }
942}
943
944#endregion Entry function
945
946Export-ModuleMember -Function @('Invoke-AdrConsistencyValidation')
947