@danmoisan/drm-copilot-mcp 1.1.9 → 1.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/out/mcp-server.js +3526 -1037
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +15 -2
  4. package/resources/claude-customizations/.claude/agents/parallel-planner.md +3 -0
  5. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +49 -14
  6. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +52 -3
  7. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +7 -1
  8. package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +103 -6
  9. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +58 -3
  10. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +37 -5
  11. package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +13 -2
  12. package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +35 -7
  13. package/resources/claude-customizations/.claude/hooks/hook-command-invocation.ps1 +483 -0
  14. package/resources/claude-customizations/.claude/hooks/hook-command-scanner.ps1 +483 -0
  15. package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +254 -5
  16. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +18 -75
  17. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConflict.psm1 +290 -0
  18. package/resources/claude-customizations/.claude/lib/cleanup-manifest/CleanupWorktreeManifest.psm1 +415 -0
  19. package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMerge.psm1 +355 -0
  20. package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMergeGrammar.psm1 +318 -0
  21. package/resources/claude-customizations/.claude/lib/project-file-merge/Resolve-MergeableConflict.ps1 +229 -0
  22. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +66 -2
  23. package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +311 -16
  24. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +42 -0
  25. package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +3 -1
  26. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +36 -2
  27. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +6 -0
  28. package/resources/claude-customizations/.claude/skills/powershell-orchestration-state-machine/SKILL.md +29 -1
  29. package/resources/claude-customizations/config/blast-radius.json +7 -0
  30. package/resources/claude-customizations/pack-manifests/core.json +7 -0
  31. package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +41 -0
  32. package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-state/SKILL.md +41 -0
  33. package/resources/codex-and-agents-customizations/.agents/skills/repo-automation-adapter/SKILL.md +26 -0
  34. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  35. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-merge-gate.ps1 +49 -3
  36. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-planning-only.ps1 +57 -12
  37. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-worktree-removal-gate.ps1 +34 -8
  38. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +6 -1
  39. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-promotion-mcp-only.ps1 +34 -7
  40. package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-invocation.ps1 +483 -0
  41. package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-scanner.ps1 +483 -0
  42. package/resources/codex-and-agents-customizations/.codex/hooks/validate-bash.ps1 +130 -2
  43. package/resources/codex-and-agents-customizations/pack-manifests/core.json +6 -1
  44. package/resources/config/orchestration-handoff-registry.json +138 -0
  45. package/resources/config/orchestration-handoff.schema.json +472 -0
  46. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +38 -0
@@ -0,0 +1,355 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Keyed-union resolution of conflicted project-file text (issue #643).
4
+
5
+ .DESCRIPTION
6
+ Splits a conflicted file into hunks, parses each side with the grammar
7
+ module, and rebuilds the file as the keyed union of the two sides: ours
8
+ entries in ours order, then theirs entries whose key ours does not carry.
9
+
10
+ Nothing here is an XML operation. A line the merge keeps is the byte-for-byte
11
+ line one side wrote, so indentation, attribute order, and line terminators
12
+ survive; the one exception is the app.config oldVersion upper bound, which is
13
+ rewritten in place so the retained redirect still covers the chosen version.
14
+
15
+ The merge refuses far more often than it resolves. A side with any line
16
+ outside the grammar, a version pair that does not rank, and a key carried at
17
+ one version by both sides with different attributes all escalate, because a
18
+ mechanical union of a conflict nobody understands is worse than a human one.
19
+ CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop.
20
+ #>
21
+
22
+ Set-StrictMode -Version Latest
23
+ $ErrorActionPreference = 'Stop'
24
+
25
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'ProjectFileMergeGrammar.psm1') -Force -ErrorAction Stop
26
+
27
+ # Conflict markers. git writes seven characters; the base section appears only in
28
+ # the diff3 and zdiff3 styles, and the separator line carries nothing else.
29
+ $script:HunkStartPattern = '^<{7}'
30
+ $script:HunkBasePattern = '^\|{7}'
31
+ $script:HunkSplitPattern = '^={7}\s*$'
32
+ $script:HunkEndPattern = '^>{7}'
33
+
34
+ # Widest window Get-UnitKeySet offers the grammar when scanning a whole file. A
35
+ # paired item and a dependentAssembly block are both far shorter than this.
36
+ $script:MaximumUnitSpan = 16
37
+
38
+ # The app.config redirect range, whose upper bound follows the chosen newVersion.
39
+ $script:OldVersionRangePattern = '(oldVersion=")([^"-]*)(-)([^"]*)(")'
40
+
41
+ function Get-ConflictHunk {
42
+ <#
43
+ .SYNOPSIS
44
+ Split conflicted text into its conflict hunks.
45
+ .DESCRIPTION
46
+ Accepts the merge style and the diff3/zdiff3 style, whose extra |||||||
47
+ section carries the base text. Indices are zero-based positions in Line.
48
+ .PARAMETER Line
49
+ The whole conflicted file, each line retaining its own terminator.
50
+ .OUTPUTS
51
+ System.Object[] of hunks carrying Start, End, Ours, Theirs, and Base, or
52
+ $null when a hunk opens and no closing marker follows it.
53
+ #>
54
+ [CmdletBinding()]
55
+ [OutputType([System.Object[]])]
56
+ param([Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Line)
57
+
58
+ $hunk = [System.Collections.Generic.List[object]]::new()
59
+ $index = 0
60
+ while ($index -lt $Line.Count) {
61
+ # Everything outside a hunk is context and is not collected here.
62
+ if (-not [regex]::IsMatch($Line[$index], $script:HunkStartPattern)) { $index++; continue }
63
+ $start = $index
64
+ $index++
65
+ $ours = [System.Collections.Generic.List[string]]::new()
66
+ $base = [System.Collections.Generic.List[string]]::new()
67
+ $theirs = [System.Collections.Generic.List[string]]::new()
68
+ $section = 'ours'
69
+ $end = -1
70
+ while ($index -lt $Line.Count) {
71
+ $text = $Line[$index]
72
+ $index++
73
+ # The two mid-hunk markers switch sections and carry no content.
74
+ if ([regex]::IsMatch($text, $script:HunkBasePattern)) { $section = 'base'; continue }
75
+ if ([regex]::IsMatch($text, $script:HunkSplitPattern)) { $section = 'theirs'; continue }
76
+ if ([regex]::IsMatch($text, $script:HunkEndPattern)) { $end = $index - 1; break }
77
+ if ($section -eq 'ours') { $ours.Add($text) } elseif ($section -eq 'base') { $base.Add($text) } else { $theirs.Add($text) }
78
+ }
79
+ # A hunk that never closes means the file is not a git conflict result.
80
+ if ($end -lt 0) { return $null }
81
+ $hunk.Add([pscustomobject]@{
82
+ Start = $start
83
+ End = $end
84
+ Ours = $ours.ToArray()
85
+ Theirs = $theirs.ToArray()
86
+ Base = $base.ToArray()
87
+ })
88
+ }
89
+ return , $hunk.ToArray()
90
+ }
91
+
92
+ function Get-UnitKeySet {
93
+ <#
94
+ .SYNOPSIS
95
+ Collect the unit keys a complete file text carries.
96
+ .DESCRIPTION
97
+ Offers the grammar a widening window at each position and skips a line no
98
+ window parses, so surrounding project structure contributes no key.
99
+ .PARAMETER Line
100
+ A complete file text, each line retaining its own terminator.
101
+ .PARAMETER Kind
102
+ 'msbuild', 'packages', or 'appconfig'.
103
+ .OUTPUTS
104
+ System.Object[] of keys, deduplicated and ordinally sorted.
105
+ #>
106
+ [CmdletBinding()]
107
+ [OutputType([System.Object[]])]
108
+ param(
109
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Line,
110
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Kind
111
+ )
112
+
113
+ $key = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
114
+ $index = 0
115
+ while ($index -lt $Line.Count) {
116
+ $limit = [System.Math]::Min($Line.Count - $index, $script:MaximumUnitSpan)
117
+ $span = 1
118
+ $consumed = 0
119
+ while ($span -le $limit) {
120
+ $unit = Get-MergeableUnit -Line @($Line[$index..($index + $span - 1)]) -Kind $Kind
121
+ # A parse of zero units is a run of blank lines, not a unit boundary.
122
+ if ($null -ne $unit -and $unit.Count -ge 1) {
123
+ foreach ($entry in $unit) { [void] $key.Add($entry.Key) }
124
+ $consumed = $span
125
+ break
126
+ }
127
+ $span++
128
+ }
129
+ # No window parsed here, so this line is structure and is stepped over.
130
+ $index += [System.Math]::Max($consumed, 1)
131
+ }
132
+
133
+ $sorted = [System.Collections.Generic.List[string]]::new($key)
134
+ $sorted.Sort([StringComparer]::Ordinal)
135
+ return , $sorted.ToArray()
136
+ }
137
+
138
+ function ConvertTo-MergeOutcome {
139
+ <#
140
+ .SYNOPSIS
141
+ Build the result record Merge-ConflictedText returns.
142
+ .DESCRIPTION
143
+ A non-null Reason is what makes the record an escalation, so the caller
144
+ never has to keep the flag and the reason in step by hand.
145
+ #>
146
+ [CmdletBinding()]
147
+ [OutputType([pscustomobject])]
148
+ param(
149
+ [AllowEmptyCollection()][AllowEmptyString()][string[]] $Line = @(),
150
+ [AllowEmptyCollection()][AllowEmptyString()][string[]] $FromOurs = @(),
151
+ [AllowEmptyCollection()][AllowEmptyString()][string[]] $FromTheirs = @(),
152
+ [AllowEmptyCollection()][object[]] $Resolution = @(),
153
+ [AllowNull()][AllowEmptyString()][string] $Reason = $null
154
+ )
155
+
156
+ return [pscustomobject]@{
157
+ Lines = $Line
158
+ EntriesAddedFromOurs = $FromOurs
159
+ EntriesAddedFromTheirs = $FromTheirs
160
+ VersionResolutions = $Resolution
161
+ Escalate = -not [string]::IsNullOrEmpty($Reason)
162
+ EscalateReason = if ([string]::IsNullOrEmpty($Reason)) { $null } else { $Reason }
163
+ }
164
+ }
165
+
166
+ function Test-UnitEquivalent {
167
+ <#
168
+ .SYNOPSIS
169
+ Report whether two units with one key are byte-equivalent.
170
+ .DESCRIPTION
171
+ Compares the keying element's attributes and then the unit's whole line
172
+ run, so a differing metadata child is a difference like any other.
173
+ #>
174
+ [CmdletBinding()]
175
+ [OutputType([bool])]
176
+ param(
177
+ [Parameter(Mandatory = $true)][object] $Left,
178
+ [Parameter(Mandatory = $true)][object] $Right
179
+ )
180
+
181
+ if ($Left.Attributes.Count -ne $Right.Attributes.Count) { return $false }
182
+ foreach ($name in $Left.Attributes.Keys) {
183
+ # A missing or differing attribute makes the two sides genuinely opposed.
184
+ if (-not $Right.Attributes.ContainsKey($name)) { return $false }
185
+ if (-not [string]::Equals($Left.Attributes[$name], $Right.Attributes[$name], [System.StringComparison]::Ordinal)) { return $false }
186
+ }
187
+ return [string]::Equals(($Left.Lines -join ''), ($Right.Lines -join ''), [System.StringComparison]::Ordinal)
188
+ }
189
+
190
+ function ConvertTo-RetargetedRedirectLine {
191
+ <#
192
+ .SYNOPSIS
193
+ Rewrite an app.config redirect's oldVersion upper bound.
194
+ .DESCRIPTION
195
+ The retained block was written for its own newVersion, so keeping the
196
+ higher newVersion without widening the range would leave the redirect
197
+ covering less than it claims.
198
+ #>
199
+ [CmdletBinding()]
200
+ [OutputType([System.Object[]])]
201
+ param(
202
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Line,
203
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $NewVersion
204
+ )
205
+
206
+ $rewritten = [System.Collections.Generic.List[string]]::new()
207
+ foreach ($text in $Line) {
208
+ # Only the range form carries an upper bound to move. The braced group
209
+ # references keep the version digits from being read as a group number.
210
+ $rewritten.Add([regex]::Replace($text, $script:OldVersionRangePattern, ('${1}${2}${3}' + $NewVersion + '${5}')))
211
+ }
212
+ return , $rewritten.ToArray()
213
+ }
214
+
215
+ function Merge-ConflictedText {
216
+ <#
217
+ .SYNOPSIS
218
+ Resolve every conflict hunk of a project file as a keyed union.
219
+ .DESCRIPTION
220
+ Ours entries keep their order and lead; theirs entries whose key ours
221
+ does not carry follow in theirs order. A key both sides carry at
222
+ different versions keeps the ranked side and records the choice; the same
223
+ key at one version with differing attributes or children escalates, as
224
+ does any side outside the grammar.
225
+ .PARAMETER Line
226
+ The whole conflicted file, each line retaining its own terminator.
227
+ .PARAMETER Kind
228
+ 'msbuild', 'packages', or 'appconfig'.
229
+ .OUTPUTS
230
+ A record carrying Lines, EntriesAddedFromOurs, EntriesAddedFromTheirs,
231
+ VersionResolutions, Escalate, and EscalateReason.
232
+ #>
233
+ [CmdletBinding()]
234
+ [OutputType([pscustomobject])]
235
+ param(
236
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Line,
237
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Kind
238
+ )
239
+
240
+ $hunk = Get-ConflictHunk -Line $Line
241
+ if ($null -eq $hunk) { return (ConvertTo-MergeOutcome -Reason 'a conflict hunk is not terminated') }
242
+
243
+ $merged = [System.Collections.Generic.List[string]]::new()
244
+ $fromOurs = [System.Collections.Generic.List[string]]::new()
245
+ $fromTheirs = [System.Collections.Generic.List[string]]::new()
246
+ $resolution = [System.Collections.Generic.List[object]]::new()
247
+ $cursor = 0
248
+ foreach ($section in $hunk) {
249
+ # Context ahead of the hunk is copied through untouched.
250
+ while ($cursor -lt $section.Start) { $merged.Add($Line[$cursor]); $cursor++ }
251
+ $oursUnit = Get-MergeableUnit -Line $section.Ours -Kind $Kind
252
+ $theirsUnit = Get-MergeableUnit -Line $section.Theirs -Kind $Kind
253
+ if ($null -eq $oursUnit -or $null -eq $theirsUnit) {
254
+ return (ConvertTo-MergeOutcome -Reason ('the hunk opening at line {0} is outside the merge grammar' -f ($section.Start + 1)))
255
+ }
256
+
257
+ $theirsByKey = @{}
258
+ foreach ($unit in $theirsUnit) { $theirsByKey[$unit.Key] = $unit }
259
+ $oursKey = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
260
+ foreach ($unit in $oursUnit) { [void] $oursKey.Add($unit.Key) }
261
+
262
+ foreach ($unit in $oursUnit) {
263
+ $kept = $unit
264
+ if ($theirsByKey.ContainsKey($unit.Key)) {
265
+ $other = $theirsByKey[$unit.Key]
266
+ $oursVersion = [string] $unit.Version
267
+ $theirsVersion = [string] $other.Version
268
+ if (-not [string]::Equals($oursVersion, $theirsVersion, [System.StringComparison]::Ordinal)) {
269
+ $choice = Compare-UnitVersion -Ours $oursVersion -Theirs $theirsVersion
270
+ # An unrankable pair is exactly the case a human must settle.
271
+ if ($choice -eq 'escalate') {
272
+ return (ConvertTo-MergeOutcome -Reason ('{0} carries versions that do not rank: {1} and {2}' -f $unit.Key, $oursVersion, $theirsVersion))
273
+ }
274
+ if ($choice -eq 'theirs') { $kept = $other }
275
+ $chosen = if ($choice -eq 'theirs') { $theirsVersion } else { $oursVersion }
276
+ $resolution.Add([pscustomobject]@{ key = $unit.Key; ours = $oursVersion; theirs = $theirsVersion; chosen = $chosen })
277
+ # The retained redirect must still cover the version it pins.
278
+ if ($Kind -eq 'appconfig') {
279
+ $kept = [pscustomobject]@{
280
+ Key = $kept.Key
281
+ Version = $chosen
282
+ Lines = (ConvertTo-RetargetedRedirectLine -Line $kept.Lines -NewVersion $chosen)
283
+ Attributes = $kept.Attributes
284
+ }
285
+ }
286
+ } elseif (-not (Test-UnitEquivalent -Left $unit -Right $other)) {
287
+ return (ConvertTo-MergeOutcome -Reason ('{0} is carried at one version with differing content on each side' -f $unit.Key))
288
+ }
289
+ }
290
+ foreach ($text in $kept.Lines) { $merged.Add($text) }
291
+ $fromOurs.Add($unit.Key)
292
+ }
293
+
294
+ foreach ($unit in $theirsUnit) {
295
+ # A key ours already placed was settled above and is not repeated.
296
+ if ($oursKey.Contains($unit.Key)) { continue }
297
+ foreach ($text in $unit.Lines) { $merged.Add($text) }
298
+ $fromTheirs.Add($unit.Key)
299
+ }
300
+ $cursor = $section.End + 1
301
+ }
302
+
303
+ # Trailing context after the last hunk is copied through untouched.
304
+ while ($cursor -lt $Line.Count) { $merged.Add($Line[$cursor]); $cursor++ }
305
+ return (ConvertTo-MergeOutcome -Line $merged.ToArray() -FromOurs $fromOurs.ToArray() -FromTheirs $fromTheirs.ToArray() -Resolution $resolution.ToArray())
306
+ }
307
+
308
+ function Test-NeverDropPostCondition {
309
+ <#
310
+ .SYNOPSIS
311
+ Report whether a merged text dropped nothing either side carried.
312
+ .DESCRIPTION
313
+ The merged key set must equal the union of the two stage key sets, and
314
+ every key the base and both sides carried must survive. The second check
315
+ is redundant with the first by construction and is asserted anyway,
316
+ because it is the property the merge exists to guarantee.
317
+ .PARAMETER MergedLine
318
+ The merged file text.
319
+ .PARAMETER OursLine
320
+ The stage-2 text.
321
+ .PARAMETER TheirsLine
322
+ The stage-3 text.
323
+ .PARAMETER BaseLine
324
+ The stage-1 text.
325
+ .PARAMETER Kind
326
+ 'msbuild', 'packages', or 'appconfig'.
327
+ .OUTPUTS
328
+ System.Boolean.
329
+ #>
330
+ [CmdletBinding()]
331
+ [OutputType([bool])]
332
+ param(
333
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $MergedLine,
334
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $OursLine,
335
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $TheirsLine,
336
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $BaseLine,
337
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Kind
338
+ )
339
+
340
+ $mergedKey = [System.Collections.Generic.HashSet[string]]::new([string[]] (Get-UnitKeySet -Line $MergedLine -Kind $Kind), [StringComparer]::Ordinal)
341
+ $oursKey = [System.Collections.Generic.HashSet[string]]::new([string[]] (Get-UnitKeySet -Line $OursLine -Kind $Kind), [StringComparer]::Ordinal)
342
+ $theirsKey = [System.Collections.Generic.HashSet[string]]::new([string[]] (Get-UnitKeySet -Line $TheirsLine -Kind $Kind), [StringComparer]::Ordinal)
343
+ $union = [System.Collections.Generic.HashSet[string]]::new($oursKey, [StringComparer]::Ordinal)
344
+ $union.UnionWith($theirsKey)
345
+ # A merged set larger than the union means an invented key; smaller, a drop.
346
+ if (-not $mergedKey.SetEquals($union)) { return $false }
347
+
348
+ foreach ($key in (Get-UnitKeySet -Line $BaseLine -Kind $Kind)) {
349
+ # Only a key both sides still carry is one the merge had to keep.
350
+ if ($oursKey.Contains($key) -and $theirsKey.Contains($key) -and -not $mergedKey.Contains($key)) { return $false }
351
+ }
352
+ return $true
353
+ }
354
+
355
+ Export-ModuleMember -Function Get-ConflictHunk, Get-UnitKeySet, Merge-ConflictedText, Test-NeverDropPostCondition
@@ -0,0 +1,318 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Line grammar for the mechanically-mergeable project-file shapes.
4
+
5
+ .DESCRIPTION
6
+ Classifies a project file into one of the three kinds the merge step admits
7
+ and parses one side of a conflict hunk into ordered, keyed units (issue #643).
8
+
9
+ The grammar is deliberately narrow: a side is parsed only when every one of
10
+ its lines is an admitted shape, and anything else yields $null, which the
11
+ caller turns into an escalation. Lines are matched as opaque text and no XML
12
+ API is used, so attribute order, indentation, self-closing spacing, and line
13
+ terminators all survive a merge: a kept line is copied, not re-serialised.
14
+ CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop.
15
+ #>
16
+
17
+ Set-StrictMode -Version Latest
18
+ $ErrorActionPreference = 'Stop'
19
+
20
+ # File extensions sharing the MSBuild item grammar, compared lower-case.
21
+ $script:MsBuildExtension = @('.csproj', '.vbproj', '.props')
22
+
23
+ # Attribute shape shared by every admitted element: name="value".
24
+ $script:AttributePattern = '([A-Za-z_][-A-Za-z0-9_.:]*)\s*=\s*"([^"]*)"'
25
+
26
+ # The five MSBuild item elements, single-line and paired-open. The single-line
27
+ # form is tested first, because the paired pattern also matches a self-closing
28
+ # line whose attribute run happens to end with a slash.
29
+ $script:MsBuildSinglePattern = '^\s*<(Compile|Analyzer|None|Content|EmbeddedResource)\s+([^<>]*?)\s*/>\s*$'
30
+ $script:MsBuildOpenPattern = '^\s*<(Compile|Analyzer|None|Content|EmbeddedResource)\s+([^<>]*?)\s*>\s*$'
31
+
32
+ # The metadata children a paired item may carry; any other child is ungrammatical.
33
+ $script:MsBuildChildPattern = '^\s*<(DependentUpon|SubType|AutoGen|DesignTime|Link|CopyToOutputDirectory|Generator|LastGenOutput)>[^<>]*</\1>\s*$'
34
+
35
+ # packages.config carries exactly one admitted element.
36
+ $script:PackagePattern = '^\s*<package\s+([^<>]*?)\s*/>\s*$'
37
+
38
+ # app.config binding redirects are keyed on the enclosing dependentAssembly block.
39
+ $script:AppConfigOpenPattern = '^\s*<dependentAssembly>\s*$'
40
+ $script:AppConfigClosePattern = '^\s*</dependentAssembly>\s*$'
41
+ $script:AppConfigChildPattern = '^\s*<(assemblyIdentity|bindingRedirect|publisherPolicy|codeBase)\s+([^<>]*?)\s*/>\s*$'
42
+
43
+ function Get-ProjectFileKind {
44
+ <#
45
+ .SYNOPSIS
46
+ Classify a repository path into a merge grammar kind.
47
+ .DESCRIPTION
48
+ Returns 'packages', 'appconfig', 'msbuild', or $null. The two .config
49
+ shapes are keyed on the whole leaf name because the .config extension is
50
+ shared with files this grammar does not admit.
51
+ .PARAMETER Path
52
+ A repository-relative or absolute path; only its leaf name is read.
53
+ .OUTPUTS
54
+ System.String, or $null when the path is not a mergeable project file.
55
+ #>
56
+ [CmdletBinding()]
57
+ [OutputType([string])]
58
+ param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $Path)
59
+
60
+ $leaf = [System.IO.Path]::GetFileName($Path)
61
+ # Whole-name shapes settle first: .config alone would admit every other one.
62
+ if ([string]::Equals($leaf, 'packages.config', [System.StringComparison]::OrdinalIgnoreCase)) { return 'packages' }
63
+ if ([string]::Equals($leaf, 'app.config', [System.StringComparison]::OrdinalIgnoreCase)) { return 'appconfig' }
64
+
65
+ # The three project shapes share one item grammar, so they share one kind.
66
+ $extension = [System.IO.Path]::GetExtension($leaf).ToLowerInvariant()
67
+ if ($script:MsBuildExtension -contains $extension) { return 'msbuild' }
68
+ return $null
69
+ }
70
+
71
+ function Get-AttributeMap {
72
+ <#
73
+ .SYNOPSIS
74
+ Read the name="value" attributes of one element's attribute run.
75
+ .DESCRIPTION
76
+ A repeated name keeps its first value, which is the value an XML reader
77
+ would have seen before rejecting the duplicate.
78
+ #>
79
+ [CmdletBinding()]
80
+ [OutputType([hashtable])]
81
+ param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $Text)
82
+
83
+ $map = @{}
84
+ foreach ($match in [regex]::Matches($Text, $script:AttributePattern)) {
85
+ # First value wins, so a malformed duplicate cannot change the key.
86
+ if (-not $map.ContainsKey($match.Groups[1].Value)) { $map[$match.Groups[1].Value] = $match.Groups[2].Value }
87
+ }
88
+ return $map
89
+ }
90
+
91
+ function Get-MsBuildUnit {
92
+ <#
93
+ .SYNOPSIS
94
+ Parse MSBuild item lines into keyed units.
95
+ .DESCRIPTION
96
+ Accepts the single-line self-closing form and the paired open/close form
97
+ whose only children are metadata elements; any other line yields $null.
98
+ #>
99
+ [CmdletBinding()]
100
+ [OutputType([System.Object[]])]
101
+ param([Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Line)
102
+
103
+ $unit = [System.Collections.Generic.List[object]]::new()
104
+ $index = 0
105
+ while ($index -lt $Line.Count) {
106
+ $text = $Line[$index]
107
+ # Blank separators inside a hunk side carry no unit and are dropped.
108
+ if ([string]::IsNullOrWhiteSpace($text)) { $index++; continue }
109
+ $single = [regex]::Match($text, $script:MsBuildSinglePattern)
110
+ if ($single.Success) {
111
+ $attribute = Get-AttributeMap -Text $single.Groups[2].Value
112
+ # An item with no Include has no key, so it cannot be reconciled.
113
+ if (-not $attribute.ContainsKey('Include')) { return $null }
114
+ $unit.Add([pscustomobject]@{
115
+ Key = ('{0}:{1}' -f $single.Groups[1].Value, $attribute['Include'])
116
+ Version = $null
117
+ Lines = @($text)
118
+ Attributes = $attribute
119
+ })
120
+ $index++
121
+ continue
122
+ }
123
+ $open = [regex]::Match($text, $script:MsBuildOpenPattern)
124
+ # Neither admitted form matched, so this whole side is ungrammatical.
125
+ if (-not $open.Success) { return $null }
126
+ $itemType = $open.Groups[1].Value
127
+ $attribute = Get-AttributeMap -Text $open.Groups[2].Value
128
+ if (-not $attribute.ContainsKey('Include')) { return $null }
129
+ $body = [System.Collections.Generic.List[string]]::new()
130
+ $body.Add($text)
131
+ $index++
132
+ $closePattern = '^\s*</{0}>\s*$' -f [regex]::Escape($itemType)
133
+ $isClosed = $false
134
+ while ($index -lt $Line.Count) {
135
+ $child = $Line[$index]
136
+ $body.Add($child)
137
+ $index++
138
+ # The matching close tag ends the unit and is part of its lines.
139
+ if ([regex]::IsMatch($child, $closePattern)) { $isClosed = $true; break }
140
+ # A child outside the metadata set means the form is not understood.
141
+ if (-not [regex]::IsMatch($child, $script:MsBuildChildPattern)) { return $null }
142
+ }
143
+ # A side ending mid-element is a truncated hunk, not a mergeable unit.
144
+ if (-not $isClosed) { return $null }
145
+ $unit.Add([pscustomobject]@{
146
+ Key = ('{0}:{1}' -f $itemType, $attribute['Include'])
147
+ Version = $null
148
+ Lines = $body.ToArray()
149
+ Attributes = $attribute
150
+ })
151
+ }
152
+ return , $unit.ToArray()
153
+ }
154
+
155
+ function Get-PackageUnit {
156
+ <#
157
+ .SYNOPSIS
158
+ Parse packages.config package lines into keyed units.
159
+ .DESCRIPTION
160
+ The key is package:<id> and the version is the version attribute, so two
161
+ sides pinning one package at different versions are comparable.
162
+ #>
163
+ [CmdletBinding()]
164
+ [OutputType([System.Object[]])]
165
+ param([Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Line)
166
+
167
+ $unit = [System.Collections.Generic.List[object]]::new()
168
+ foreach ($text in $Line) {
169
+ # Blank separators carry no unit.
170
+ if ([string]::IsNullOrWhiteSpace($text)) { continue }
171
+ $match = [regex]::Match($text, $script:PackagePattern)
172
+ # Anything other than a package element is outside the grammar.
173
+ if (-not $match.Success) { return $null }
174
+ $attribute = Get-AttributeMap -Text $match.Groups[1].Value
175
+ # Without an id there is no key to reconcile the two sides on.
176
+ if (-not $attribute.ContainsKey('id')) { return $null }
177
+ $version = if ($attribute.ContainsKey('version')) { $attribute['version'] } else { $null }
178
+ $unit.Add([pscustomobject]@{
179
+ Key = ('package:{0}' -f $attribute['id'])
180
+ Version = $version
181
+ Lines = @($text)
182
+ Attributes = $attribute
183
+ })
184
+ }
185
+ return , $unit.ToArray()
186
+ }
187
+
188
+ function Get-BindingRedirectUnit {
189
+ <#
190
+ .SYNOPSIS
191
+ Parse app.config dependentAssembly blocks into keyed units.
192
+ .DESCRIPTION
193
+ The key is bindingRedirect:<assemblyIdentity name> and the version is the
194
+ bindingRedirect newVersion, so two redirects for one assembly compare.
195
+ #>
196
+ [CmdletBinding()]
197
+ [OutputType([System.Object[]])]
198
+ param([Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Line)
199
+
200
+ $unit = [System.Collections.Generic.List[object]]::new()
201
+ $index = 0
202
+ while ($index -lt $Line.Count) {
203
+ $text = $Line[$index]
204
+ # Blank separators between blocks carry no unit.
205
+ if ([string]::IsNullOrWhiteSpace($text)) { $index++; continue }
206
+ # Only a whole dependentAssembly block is admitted; a bare child is not.
207
+ if (-not [regex]::IsMatch($text, $script:AppConfigOpenPattern)) { return $null }
208
+ $body = [System.Collections.Generic.List[string]]::new()
209
+ $body.Add($text)
210
+ $index++
211
+ $name = $null
212
+ $identity = @{}
213
+ $newVersion = $null
214
+ $isClosed = $false
215
+ while ($index -lt $Line.Count) {
216
+ $child = $Line[$index]
217
+ $body.Add($child)
218
+ $index++
219
+ if ([regex]::IsMatch($child, $script:AppConfigClosePattern)) { $isClosed = $true; break }
220
+ $match = [regex]::Match($child, $script:AppConfigChildPattern)
221
+ # An unrecognised child means the block is not understood.
222
+ if (-not $match.Success) { return $null }
223
+ $attribute = Get-AttributeMap -Text $match.Groups[2].Value
224
+ # The identity supplies the key; the redirect supplies the version.
225
+ if ($match.Groups[1].Value -eq 'assemblyIdentity' -and $attribute.ContainsKey('name')) {
226
+ $name = $attribute['name']
227
+ $identity = $attribute
228
+ }
229
+ if ($match.Groups[1].Value -eq 'bindingRedirect' -and $attribute.ContainsKey('newVersion')) { $newVersion = $attribute['newVersion'] }
230
+ }
231
+ # A block with no close tag or no identity name cannot be keyed.
232
+ if (-not $isClosed -or $null -eq $name) { return $null }
233
+ $unit.Add([pscustomobject]@{
234
+ Key = ('bindingRedirect:{0}' -f $name)
235
+ Version = $newVersion
236
+ Lines = $body.ToArray()
237
+ Attributes = $identity
238
+ })
239
+ }
240
+ return , $unit.ToArray()
241
+ }
242
+
243
+ function Get-MergeableUnit {
244
+ <#
245
+ .SYNOPSIS
246
+ Parse one side of a conflict hunk into ordered, keyed units.
247
+ .DESCRIPTION
248
+ Dispatches to the parser for the supplied kind. The whole side is parsed
249
+ or none of it is: one line outside the grammar yields $null, which the
250
+ merge turns into an escalation rather than a partial resolution.
251
+ .PARAMETER Line
252
+ The lines of one hunk side, each retaining its own terminator.
253
+ .PARAMETER Kind
254
+ 'msbuild', 'packages', or 'appconfig' from Get-ProjectFileKind.
255
+ .OUTPUTS
256
+ System.Object[] of units carrying Key, Version, Lines, and Attributes in
257
+ side order, or $null when the side is ungrammatical.
258
+ #>
259
+ [CmdletBinding()]
260
+ [OutputType([System.Object[]])]
261
+ param(
262
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Line,
263
+ [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Kind
264
+ )
265
+
266
+ # One parser per kind. An unrecognised kind is ungrammatical rather than
267
+ # defaulting to a parser, so a typo cannot silently merge a file.
268
+ if ($Kind -eq 'msbuild') {
269
+ $unit = Get-MsBuildUnit -Line $Line
270
+ } elseif ($Kind -eq 'packages') {
271
+ $unit = Get-PackageUnit -Line $Line
272
+ } elseif ($Kind -eq 'appconfig') {
273
+ $unit = Get-BindingRedirectUnit -Line $Line
274
+ } else {
275
+ return $null
276
+ }
277
+
278
+ # The comma keeps an empty parse distinguishable from a failed parse: without
279
+ # it an empty array returns as $null and a clean side would read as escalate.
280
+ if ($null -eq $unit) { return $null }
281
+ return , $unit
282
+ }
283
+
284
+ function Compare-UnitVersion {
285
+ <#
286
+ .SYNOPSIS
287
+ Rank two version strings recorded for the same unit key.
288
+ .DESCRIPTION
289
+ Returns 'ours', 'theirs', or 'equal' when both sides parse as
290
+ System.Version, and 'escalate' otherwise: a prerelease or non-numeric
291
+ version such as 1.0.0-beta1 cannot be ranked mechanically.
292
+ .PARAMETER Ours
293
+ The version recorded on the ours side. May be absent.
294
+ .PARAMETER Theirs
295
+ The version recorded on the theirs side. May be absent.
296
+ .OUTPUTS
297
+ System.String: 'ours', 'theirs', 'equal', or 'escalate'.
298
+ #>
299
+ [CmdletBinding()]
300
+ [OutputType([string])]
301
+ param(
302
+ [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string] $Ours,
303
+ [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string] $Theirs
304
+ )
305
+
306
+ [System.Version] $oursVersion = $null
307
+ [System.Version] $theirsVersion = $null
308
+ # An unparseable side on either hand is unrankable, so neither is chosen.
309
+ if (-not [System.Version]::TryParse($Ours, [ref] $oursVersion)) { return 'escalate' }
310
+ if (-not [System.Version]::TryParse($Theirs, [ref] $theirsVersion)) { return 'escalate' }
311
+
312
+ # Strictly higher wins; anything else is an equal pin needing no choice.
313
+ if ($oursVersion -gt $theirsVersion) { return 'ours' }
314
+ if ($theirsVersion -gt $oursVersion) { return 'theirs' }
315
+ return 'equal'
316
+ }
317
+
318
+ Export-ModuleMember -Function Get-ProjectFileKind, Get-MergeableUnit, Compare-UnitVersion