@danmoisan/drm-copilot-mcp 1.1.10 → 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 +3116 -910
  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 +218 -6
  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
@@ -39,6 +39,11 @@ param(
39
39
  )
40
40
 
41
41
  Import-Module (Join-Path $PSScriptRoot '../lib/hook-payload/HookPayload.psm1') -Force
42
+ # Shared command-line parser (issue #545). Both detectors below run against the segment
43
+ # list rather than against the unsegmented command string, so a dangerous phrase quoted
44
+ # inside a message body is no longer a match and a relocating spelling no longer escapes.
45
+ . (Join-Path $PSScriptRoot 'hook-command-scanner.ps1')
46
+ . (Join-Path $PSScriptRoot 'hook-command-invocation.ps1')
42
47
 
43
48
  function Get-BlockedBashPattern {
44
49
  [CmdletBinding()]
@@ -55,7 +60,119 @@ function Get-BlockedBashPattern {
55
60
  )
56
61
  }
57
62
 
63
+ function Test-BlockedPatternTokenRun {
64
+ <#
65
+ .SYNOPSIS
66
+ Report whether a literal's token sequence occurs as a contiguous run in a token list.
67
+ .DESCRIPTION
68
+ Whole-token equality, element by element. This is the comparison primitive that
69
+ replaces String.Contains. It is what makes '--force-with-lease' stop matching the
70
+ literal 'git push --force': the two are different tokens, whereas one is a substring
71
+ of the other.
72
+ .OUTPUTS
73
+ System.Boolean
74
+ #>
75
+ [CmdletBinding()]
76
+ [OutputType([bool])]
77
+ param(
78
+ [Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Token,
79
+ [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]] $PatternToken
80
+ )
81
+
82
+ if ($PatternToken.Count -eq 0 -or $Token.Count -lt $PatternToken.Count) {
83
+ return $false
84
+ }
85
+
86
+ for ($start = 0; $start -le $Token.Count - $PatternToken.Count; $start++) {
87
+ $matched = $true
88
+ for ($offset = 0; $offset -lt $PatternToken.Count; $offset++) {
89
+ if ($Token[$start + $offset] -ne $PatternToken[$offset]) {
90
+ $matched = $false
91
+ break
92
+ }
93
+ }
94
+ if ($matched) {
95
+ return $true
96
+ }
97
+ }
98
+
99
+ return $false
100
+ }
101
+
102
+ function Get-BlockedStructuralGitMatch {
103
+ <#
104
+ .SYNOPSIS
105
+ Report the denylist literal a segment's structural git invocation stands for, or $null.
106
+ .DESCRIPTION
107
+ The flag conjunction is required rather than optional. Classifying on the bare
108
+ subcommand would deny 'git push --force-with-lease origin HEAD', which spec Test
109
+ Strategy row AT-8 requires to allow, and would deny 'git reset --soft HEAD~1'.
110
+
111
+ When a segment carries both '--force' and '-f' the '--force' leg is evaluated first,
112
+ so the returned value is 'git push --force'.
113
+ .OUTPUTS
114
+ System.String or $null
115
+ #>
116
+ [CmdletBinding()]
117
+ [OutputType([string])]
118
+ param(
119
+ [Parameter(Mandatory)][AllowEmptyString()][string] $SegmentText,
120
+ [Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Token
121
+ )
122
+
123
+ if (Test-CommandLineInvocation -CommandText $SegmentText -CommandWord 'git' -SubcommandPath @('push')) {
124
+ if ($Token -contains '--force') { return 'git push --force' }
125
+ if ($Token -contains '-f') { return 'git push -f' }
126
+ }
127
+ if (Test-CommandLineInvocation -CommandText $SegmentText -CommandWord 'git' -SubcommandPath @('reset')) {
128
+ if ($Token -contains '--hard') { return 'git reset --hard' }
129
+ }
130
+
131
+ return $null
132
+ }
133
+
58
134
  function Get-BlockedPatternMatch {
135
+ <#
136
+ .SYNOPSIS
137
+ Return the denylist literal a command matches, or $null.
138
+ .DESCRIPTION
139
+ Two legs, in this order, over the segment list produced by Read-CommandLineSegment.
140
+
141
+ Leg 1 (literal): each of the six byte-unchanged literals from Get-BlockedBashPattern
142
+ is split into its own whitespace-delimited token sequence and reported as a match
143
+ when that sequence occurs as a CONTIGUOUS RUN inside a segment's Tokens, every
144
+ element compared by whole-token equality. The value returned is the literal string
145
+ itself, and the literals are tested in their existing declaration order. Because a
146
+ quoted span is one token, 'git commit -m "why rm -rf is banned"' no longer matches.
147
+
148
+ Leg 1 has a second condition, the wrapper carve-out of D2 Piece 2 together with the two
149
+ other clauses under which the scanner selects raw scan text. A wrapper's quoted argument is
150
+ a nested command line rather than inert data, and ConvertTo-CommandLineToken collapses a
151
+ balanced quoted span into ONE token, so a multi-token literal can never form a contiguous
152
+ token run inside it. The same masking hides a literal carried inside a quoted span or a
153
+ heredoc that never closes. For a segment that is wrapper-led, that carries a live
154
+ substitution, or whose quoting or heredoc did not close, the scanner already selects
155
+ RawText as ScanText, and this leg reads that field with an Ordinal IndexOf, matching the
156
+ culture-insensitive String.Contains it replaced. The three disjuncts here are exactly the
157
+ three clauses of that ScanText selection, so no segment the scanner scans raw is left
158
+ unscanned by this leg. That is what keeps both 'bash -c "rm -rf /tmp/x"' and
159
+ 'echo "rm -rf /tmp/x' denying. A segment matching none of the three is never scanned this
160
+ way, so 'git commit -m "docs: explain why rm -rf is banned"' still allows.
161
+
162
+ Leg 2 (structural): a relocating spelling such as 'git -C ../wt push --force origin
163
+ HEAD' contains no literal as a token run, so it is classified structurally instead.
164
+
165
+ Leg 1 is evaluated IN FULL, over every segment and every literal, before any leg 2
166
+ evaluation. That ordering is what keeps 'git push origin --force' returning the
167
+ literal 'git push origin --force' rather than the leg 2 value 'git push --force',
168
+ which is what the existing case at tests/scripts/claude-hooks/validate-bash.Tests.ps1
169
+ line 29 asserts.
170
+
171
+ Spec D11.3 rules that rule R2 governs the literal TEXT, not the comparison operator,
172
+ so all six literals stay byte-unchanged; only the comparison primitive changed.
173
+ .OUTPUTS
174
+ System.String or $null
175
+ #>
59
176
  [CmdletBinding()]
60
177
  [OutputType([string])]
61
178
  param(
@@ -69,9 +186,25 @@ function Get-BlockedPatternMatch {
69
186
  return $null
70
187
  }
71
188
 
189
+ $segments = @(Read-CommandLineSegment -CommandText $Command)
190
+
72
191
  foreach ($pattern in (Get-BlockedBashPattern)) {
73
- if ($Command.Contains($pattern)) {
74
- return $pattern
192
+ $patternTokens = [string[]]@($pattern -split '\s+' | Where-Object { $_ })
193
+ foreach ($segment in $segments) {
194
+ if (Test-BlockedPatternTokenRun -Token @($segment.Tokens) -PatternToken $patternTokens) {
195
+ return $pattern
196
+ }
197
+ if (($segment.IsWrapperLed -or $segment.HasLiveSubstitution -or $segment.Unbalanced) -and
198
+ $segment.ScanText.IndexOf($pattern, [System.StringComparison]::Ordinal) -ge 0) {
199
+ return $pattern
200
+ }
201
+ }
202
+ }
203
+
204
+ foreach ($segment in $segments) {
205
+ $structural = Get-BlockedStructuralGitMatch -SegmentText $segment.RawText -Token @($segment.Tokens)
206
+ if ($structural) {
207
+ return $structural
75
208
  }
76
209
  }
77
210
 
@@ -88,7 +221,50 @@ function Get-BlockedPatternMatch {
88
221
  # never chain `cd` with one of these in the same command.
89
222
  $script:CdChainedReadCommandPattern = 'cd\s+\S.*?(&&|;)\s*(grep|cat|head|tail|less|more|awk|sed\s+-n)\b'
90
223
 
224
+ # The read-command family the pattern above enumerates, as command words. Retained
225
+ # alongside the pattern so the rule R2 literal-text obligation stays visibly discharged:
226
+ # the pattern string is byte-unchanged and is kept as a declared constant, while the
227
+ # evaluation moves to the segment list.
228
+ $script:CdChainedReadCommandWords = @('grep', 'cat', 'head', 'tail', 'less', 'more', 'awk', 'sed')
229
+
91
230
  function Get-CdChainedReadCommandMatch {
231
+ <#
232
+ .SYNOPSIS
233
+ Return the read command chained after a cd on the same command line, or $null.
234
+ .DESCRIPTION
235
+ Walks the segment list in order and reports a match when a segment whose CommandWord
236
+ is 'cd' and whose Tokens count is at least two is followed, at ANY later position in
237
+ the segment list, by a segment whose CommandWord is one of the read-command family.
238
+ The returned value is that later segment's CommandWord, except that a 'sed' segment
239
+ matches only when its second token is '-n' and then returns 'sed -n'. When more than
240
+ one later segment qualifies, the earliest supplies the return value.
241
+
242
+ The regex could not simply be evaluated per segment: '&&' and ';' are segment
243
+ delimiters under spec D2 Piece 1, so no single segment ever contains both sides of
244
+ the chain.
245
+
246
+ A second leg therefore runs first, before the walk: the retained regex constant is
247
+ evaluated against the ScanText of each segment the scanner already reads raw, which
248
+ is where a wrapper's quoted argument collapses a whole nested chain into one segment
249
+ the walk cannot decompose. The two legs are complementary and neither replaces the
250
+ other, so the ordering between them changes only which leg reports a chain that both
251
+ could see, never whether a chain is reported at all.
252
+
253
+ Adjacency is deliberately NOT required. The retained pattern places a lazy '.*?'
254
+ between the cd argument and the delimiter, so 'cd /x && npm test && grep foo bar.txt'
255
+ is denied today; requiring the read segment to follow the cd segment immediately
256
+ would turn that existing denial into an allow, which acceptance criterion 9 forbids.
257
+ This formulation also widens the delimiter set from '&&' and ';' to every segment
258
+ delimiter, so every command line in which a real cd segment precedes a real read
259
+ segment and is denied today is still denied.
260
+
261
+ One narrowing is intended and is stated rather than left implicit: a cd-then-read
262
+ phrase occurring only inside a quoted span or a heredoc body is denied today and
263
+ allows after this change, because the scanner masks that span before the segment list
264
+ is built. That narrowing is the over-match fix this issue exists to deliver.
265
+ .OUTPUTS
266
+ System.String or $null
267
+ #>
92
268
  [CmdletBinding()]
93
269
  [OutputType([string])]
94
270
  param(
@@ -102,12 +278,48 @@ function Get-CdChainedReadCommandMatch {
102
278
  return $null
103
279
  }
104
280
 
105
- $match = [regex]::Match($Command, $script:CdChainedReadCommandPattern)
106
- if (-not $match.Success) {
107
- return $null
281
+ $segments = @(Read-CommandLineSegment -CommandText $Command)
282
+
283
+ # D12 call-site row for line 105: the retained pattern, evaluated per segment against
284
+ # ScanText. Complementary to the CommandWord walk below rather than a replacement for it.
285
+ # '&&' and ';' delimit segments, so an unquoted chain never puts both halves in one
286
+ # segment and only the walk can see it; a wrapper-led chain sits entirely inside one
287
+ # segment whose command word is the wrapper, and only this leg can see that. Restricting
288
+ # the leg to segments the scanner already scans raw preserves the intended narrowing:
289
+ # echo "cd /x && head f" is masked and still allows.
290
+ foreach ($segment in $segments) {
291
+ if ((Test-CommandLineSegmentRawScan -Segment $segment) -and
292
+ $segment.ScanText -match $script:CdChainedReadCommandPattern) {
293
+ return ($Matches[2] -replace '\s+', ' ')
294
+ }
108
295
  }
109
296
 
110
- return $match.Groups[2].Value
297
+ $seenCd = $false
298
+
299
+ foreach ($segment in $segments) {
300
+ $tokens = @($segment.Tokens)
301
+ $word = [string]$segment.CommandWord
302
+
303
+ if (-not $seenCd) {
304
+ if ($word -eq 'cd' -and $tokens.Count -ge 2) {
305
+ $seenCd = $true
306
+ }
307
+ continue
308
+ }
309
+
310
+ if ($script:CdChainedReadCommandWords -notcontains $word) {
311
+ continue
312
+ }
313
+ if ($word -eq 'sed') {
314
+ if ($tokens.Count -ge 2 -and $tokens[1] -eq '-n') {
315
+ return 'sed -n'
316
+ }
317
+ continue
318
+ }
319
+ return $word
320
+ }
321
+
322
+ return $null
111
323
  }
112
324
 
113
325
  function Get-BashBlockReason {
@@ -6,10 +6,11 @@
6
6
  Destination-runtime PowerShell facade for the blast-radius library, porting
7
7
  scripts/dev_tools/compute_blast_radius.py (derive_blast_radius,
8
8
  radius_from_observed_paths, _feature_folder_glob) and
9
- scripts/dev_tools/_blast_radius_conflicts.py (conflicts,
10
- _smallest_path_overlap, _smallest_common). It imports the extraction, glob,
11
- truth-table, and validation modules that sit beside it and re-exports the
12
- five functions the spec PowerShell surface fixes:
9
+ scripts/dev_tools/_blast_radius_conflicts.py (conflicts). The ports of
10
+ _smallest_path_overlap and _smallest_common, and the mechanically-mergeable
11
+ path exclusion, live in BlastRadiusConflict.psm1. It imports the extraction,
12
+ glob, truth-table, conflict, and validation modules that sit beside it and
13
+ re-exports the five functions the spec PowerShell surface fixes:
13
14
 
14
15
  - Get-PlanPaths port of extract_plan_paths
15
16
  - Get-BlastRadius port of derive_blast_radius
@@ -58,6 +59,7 @@ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1')
58
59
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force -ErrorAction Stop
59
60
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusNormalization.psm1') -Force -ErrorAction Stop
60
61
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusValidation.psm1') -Force -ErrorAction Stop
62
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConflict.psm1') -Force -ErrorAction Stop
61
63
 
62
64
  # Feature-folder handling. Every radius contains its own feature folder, and a
63
65
  # caller may pass either a bare folder name or an already-qualified path.
@@ -81,10 +83,6 @@ $script:ConflictModuleOverlap = 'module_overlap'
81
83
  $script:ConflictSharedSurfaceOverlap = 'shared_surface_overlap'
82
84
  $script:ConflictContractDependency = 'contract_dependency'
83
85
 
84
- # Separator used in an overlapping-pair detail string. The pair is ordered
85
- # ordinally before formatting so the detail is identical in both argument orders.
86
- $script:PairDetailSeparator = ' ~ '
87
-
88
86
 
89
87
  # Port of _feature_folder_glob. Accepting an already-qualified path avoids
90
88
  # producing a doubled docs/features/active/docs/features/active/... entry when a
@@ -341,68 +339,6 @@ function Get-BlastRadiusFromObservedPaths {
341
339
  }
342
340
  }
343
341
 
344
- # Port of _smallest_path_overlap. Each overlapping pair is ordered before it is
345
- # recorded, so the minimum is taken over a set that does not depend on argument
346
- # order; that is what makes the reported detail symmetric.
347
- function Get-SmallestPathOverlap {
348
- [CmdletBinding()]
349
- [OutputType([string])]
350
- param(
351
- [Parameter(Mandatory = $true)]
352
- [AllowEmptyCollection()]
353
- [AllowEmptyString()]
354
- [string[]] $PathA,
355
- [Parameter(Mandatory = $true)]
356
- [AllowEmptyCollection()]
357
- [AllowEmptyString()]
358
- [string[]] $PathB
359
- )
360
-
361
- $detail = [System.Collections.Generic.List[string]]::new()
362
- foreach ($entryA in $PathA) {
363
- foreach ($entryB in $PathB) {
364
- if (-not (Test-EntryOverlap -EntryA $entryA -EntryB $entryB)) {
365
- continue
366
- }
367
- $ordered = if ([string]::CompareOrdinal($entryA, $entryB) -le 0) {
368
- @($entryA, $entryB)
369
- } else {
370
- @($entryB, $entryA)
371
- }
372
- $detail.Add($ordered -join $script:PairDetailSeparator)
373
- }
374
- }
375
-
376
- return (Get-OrdinalSmallestEntry -Entry $detail.ToArray())
377
- }
378
-
379
- # Port of _smallest_common. Two empty collections share nothing, so the result is
380
- # $null and the level contributes no reason.
381
- function Get-SmallestCommonEntry {
382
- [CmdletBinding()]
383
- [OutputType([string])]
384
- param(
385
- [Parameter(Mandatory = $true)]
386
- [AllowEmptyCollection()]
387
- [AllowEmptyString()]
388
- [string[]] $Left,
389
- [Parameter(Mandatory = $true)]
390
- [AllowEmptyCollection()]
391
- [AllowEmptyString()]
392
- [string[]] $Right
393
- )
394
-
395
- $rightSet = [System.Collections.Generic.HashSet[string]]::new($Right, [StringComparer]::Ordinal)
396
- $common = [System.Collections.Generic.List[string]]::new()
397
- foreach ($entry in $Left) {
398
- if ($rightSet.Contains($entry)) {
399
- $common.Add($entry)
400
- }
401
- }
402
-
403
- return (Get-OrdinalSmallestEntry -Entry $common.ToArray())
404
- }
405
-
406
342
  function Test-BlastRadiusConflict {
407
343
  <#
408
344
  .SYNOPSIS
@@ -423,9 +359,10 @@ function Test-BlastRadiusConflict {
423
359
  Second radius record.
424
360
 
425
361
  .PARAMETER Config
426
- Parsed config/blast-radius.json. The relation reads no key from it today;
427
- it is validated and kept in the signature because the contract is frozen
428
- for downstream consumers.
362
+ Parsed config/blast-radius.json. The relation reads exactly one key from
363
+ it, mergeable_paths, whose entries are dropped from both radii before the
364
+ path comparison; the mapping is otherwise validated and kept in the
365
+ signature because the contract is frozen for downstream consumers.
429
366
 
430
367
  .OUTPUTS
431
368
  System.Collections.Hashtable. Keys conflict (a boolean) and reasons (an
@@ -461,8 +398,14 @@ function Test-BlastRadiusConflict {
461
398
  $right = ConvertTo-NormalizedBlastRadius -Radius $RadiusB
462
399
 
463
400
  $reason = [System.Collections.Generic.List[hashtable]]::new()
464
- $pathDetail = Get-SmallestPathOverlap -PathA ([string[]]@($left['paths'])) `
465
- -PathB ([string[]]@($right['paths']))
401
+ # The mechanically-mergeable exclusion lives only here: it filters the two
402
+ # collections this comparison reads and rewrites no radius record, so a
403
+ # derived, declared, or observed radius still lists every project file it
404
+ # cited (issue #643).
405
+ $mergeable = [string[]]@(Get-ConfigMergeablePath -Config $Config)
406
+ $pathDetail = Get-SmallestPathOverlap `
407
+ -PathA ([string[]]@(Get-NonMergeablePathEntry -Entry ([string[]]@($left['paths'])) -MergeablePath $mergeable)) `
408
+ -PathB ([string[]]@(Get-NonMergeablePathEntry -Entry ([string[]]@($right['paths'])) -MergeablePath $mergeable))
466
409
  if ($null -ne $pathDetail) {
467
410
  $reason.Add(@{ kind = $script:ConflictPathOverlap; detail = $pathDetail })
468
411
  }
@@ -0,0 +1,290 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Mechanically-mergeable path exclusion and the shared overlap helpers.
4
+
5
+ .DESCRIPTION
6
+ Destination-runtime PowerShell mirror of
7
+ scripts/dev_tools/_blast_radius_mergeable.py (config_mergeable_paths,
8
+ matches_mergeable_path, exclude_mergeable_paths), plus the two overlap helpers
9
+ the relation calls (ports of _smallest_path_overlap and _smallest_common,
10
+ relocated here from BlastRadius.psm1).
11
+
12
+ The mergeable path class names the project-file shapes whose overlap a merge
13
+ step can reconcile without re-delegating the work (issue #643). Two items
14
+ touching the same .csproj are not genuinely in contention, so the class is
15
+ removed from both radii immediately before the relation compares them.
16
+
17
+ Parity notes for maintainers:
18
+ - The exclusion is applied ONLY inside Test-BlastRadiusConflict and changes
19
+ no radius record: a radius still lists every project file it cited, so
20
+ drift detection and validation see the paths they saw before this key.
21
+ - The key is optional and fail-closed: an absent mergeable_paths key and an
22
+ empty list both exclude nothing and reproduce pre-change behaviour.
23
+ - Every comparison is ordinal, matching the Python mirror's str equality.
24
+ CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop.
25
+ #>
26
+
27
+ Set-StrictMode -Version Latest
28
+ $ErrorActionPreference = 'Stop'
29
+
30
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force -ErrorAction Stop
31
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force -ErrorAction Stop
32
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusNormalization.psm1') -Force -ErrorAction Stop
33
+
34
+ # Truth-table key naming the class, declared as a constant so the reader, its
35
+ # tests, and the Python mirror all name one string rather than a literal.
36
+ $script:ConfigMergeablePathKey = 'mergeable_paths'
37
+
38
+ # Prefix making a configured pattern match at any depth. It is stripped in the
39
+ # third matching step so a root-level file can satisfy it (Test-MergeablePath).
40
+ $script:AnyDepthPrefix = '**/'
41
+
42
+ # Separator used in an overlapping-pair detail string. The pair is ordered
43
+ # ordinally before formatting so the detail is identical in both argument orders.
44
+ $script:PairDetailSeparator = ' ~ '
45
+
46
+
47
+ function Get-ConfigMergeablePath {
48
+ <#
49
+ .SYNOPSIS
50
+ Read the mechanically-mergeable path list from the truth table.
51
+
52
+ .DESCRIPTION
53
+ Port of config_mergeable_paths. The entries name project-file shapes,
54
+ normally anchored globs such as **/*.csproj (issue #643).
55
+
56
+ .PARAMETER Config
57
+ Parsed config/blast-radius.json. Only the mergeable_paths key is read.
58
+
59
+ .OUTPUTS
60
+ System.Object[]. Entries sorted and deduplicated by the underlying reader.
61
+ A config with no mergeable_paths key yields an empty array.
62
+ #>
63
+ [CmdletBinding()]
64
+ [OutputType([System.Object[]])]
65
+ param(
66
+ [Parameter(Mandatory = $true)]
67
+ [AllowNull()]
68
+ [object] $Config
69
+ )
70
+
71
+ return @(Get-ConfigStringList -Config $Config -Key $script:ConfigMergeablePathKey)
72
+ }
73
+
74
+ function Test-MergeablePath {
75
+ <#
76
+ .SYNOPSIS
77
+ Report whether one radius entry belongs to the mergeable path class.
78
+
79
+ .DESCRIPTION
80
+ Port of matches_mergeable_path. Three comparisons apply in order. The
81
+ first two are the read-by-mandate rules, delegated to Test-MandateRead
82
+ rather than restated: ordinal equality, the only rule that can settle a
83
+ glob entry, then glob containment for a concrete entry only. The third is
84
+ specific to this class, because an anchored pattern requires a separator
85
+ and so excludes a root-level file; retesting a concrete entry with the
86
+ anchor removed admits packages.config at the repository root.
87
+
88
+ .PARAMETER Entry
89
+ One radius paths entry: a concrete repository-relative path or a glob.
90
+
91
+ .PARAMETER MergeablePath
92
+ Configured patterns from Get-ConfigMergeablePath. An empty collection
93
+ matches nothing.
94
+
95
+ .OUTPUTS
96
+ System.Boolean. True when the entry belongs to the mergeable class and is
97
+ therefore excluded from the comparison. A glob entry is never mergeable
98
+ unless it equals a configured pattern character for character.
99
+ #>
100
+ [CmdletBinding()]
101
+ [OutputType([bool])]
102
+ param(
103
+ [Parameter(Mandatory = $true)]
104
+ [AllowEmptyString()]
105
+ [string] $Entry,
106
+ [Parameter(Mandatory = $true)]
107
+ [AllowEmptyCollection()]
108
+ [AllowEmptyString()]
109
+ [string[]] $MergeablePath
110
+ )
111
+
112
+ # Steps one and two are the mandate-read rules verbatim, reused rather than
113
+ # duplicated: a divergence would silently split two exclusions that share a
114
+ # vocabulary.
115
+ if (Test-MandateRead -Entry $Entry -MandateRead $MergeablePath) {
116
+ return $true
117
+ }
118
+
119
+ # A glob entry that did not match exactly is left alone. Only a concrete path
120
+ # reaches the anchor-stripping step.
121
+ if (Test-GlobEntry -Entry $Entry) {
122
+ return $false
123
+ }
124
+
125
+ # Retest against each anchored pattern with its anchor removed, which is the
126
+ # only way a root-level file can satisfy an anchored pattern.
127
+ foreach ($pattern in $MergeablePath) {
128
+ if (-not $pattern.StartsWith($script:AnyDepthPrefix, [System.StringComparison]::Ordinal)) {
129
+ continue
130
+ }
131
+
132
+ $stripped = $pattern.Substring($script:AnyDepthPrefix.Length)
133
+ # A stripped pattern that still carries a wildcard is a glob and is
134
+ # matched as one; a wildcard-free remainder can only be compared for
135
+ # ordinal equality.
136
+ if (Test-GlobEntry -Entry $stripped) {
137
+ if (Test-GlobMatch -Pattern $stripped -Candidate $Entry) {
138
+ return $true
139
+ }
140
+ } elseif ([string]::Equals($stripped, $Entry, [System.StringComparison]::Ordinal)) {
141
+ return $true
142
+ }
143
+ }
144
+
145
+ return $false
146
+ }
147
+
148
+ function Get-NonMergeablePathEntry {
149
+ <#
150
+ .SYNOPSIS
151
+ Drop every mechanically-mergeable entry from a collection of paths.
152
+
153
+ .DESCRIPTION
154
+ Port of exclude_mergeable_paths. The caller passes the content by value,
155
+ so the radius record is never rewritten; only the contention comparison
156
+ sees the filtered collection.
157
+
158
+ .PARAMETER Entry
159
+ Radius paths entries to filter. An empty collection is accepted.
160
+
161
+ .PARAMETER MergeablePath
162
+ Configured patterns from Get-ConfigMergeablePath. An empty collection
163
+ excludes nothing, so the returned content equals the input.
164
+
165
+ .OUTPUTS
166
+ System.Object[]. Surviving entries, deduplicated and ordinally sorted.
167
+ #>
168
+ [CmdletBinding()]
169
+ [OutputType([System.Object[]])]
170
+ param(
171
+ [Parameter(Mandatory = $true)]
172
+ [AllowEmptyCollection()]
173
+ [AllowEmptyString()]
174
+ [string[]] $Entry,
175
+ [Parameter(Mandatory = $true)]
176
+ [AllowEmptyCollection()]
177
+ [AllowEmptyString()]
178
+ [string[]] $MergeablePath
179
+ )
180
+
181
+ $survivor = [System.Collections.Generic.List[string]]::new()
182
+ foreach ($candidate in $Entry) {
183
+ # Deduplication and ordering are the sorter's job, matching the Python
184
+ # port's set-then-sorted construction.
185
+ if (-not (Test-MergeablePath -Entry $candidate -MergeablePath $MergeablePath)) {
186
+ $survivor.Add($candidate)
187
+ }
188
+ }
189
+
190
+ return @(Get-OrdinalSortedEntry -Entry $survivor.ToArray())
191
+ }
192
+
193
+ function Get-SmallestPathOverlap {
194
+ <#
195
+ .SYNOPSIS
196
+ Return the ordinally smallest overlapping path pair, or $null.
197
+
198
+ .DESCRIPTION
199
+ Port of _smallest_path_overlap. Each overlapping pair is ordered before
200
+ it is recorded, so the minimum is taken over a set that does not depend
201
+ on argument order; that is what makes the reported detail symmetric.
202
+
203
+ .PARAMETER PathA
204
+ First radius path collection. An empty collection is accepted.
205
+
206
+ .PARAMETER PathB
207
+ Second radius path collection. An empty collection is accepted.
208
+
209
+ .OUTPUTS
210
+ System.String. The smallest joined pair, or $null when nothing overlaps.
211
+ #>
212
+ [CmdletBinding()]
213
+ [OutputType([string])]
214
+ param(
215
+ [Parameter(Mandatory = $true)]
216
+ [AllowEmptyCollection()]
217
+ [AllowEmptyString()]
218
+ [string[]] $PathA,
219
+ [Parameter(Mandatory = $true)]
220
+ [AllowEmptyCollection()]
221
+ [AllowEmptyString()]
222
+ [string[]] $PathB
223
+ )
224
+
225
+ $detail = [System.Collections.Generic.List[string]]::new()
226
+ foreach ($entryA in $PathA) {
227
+ foreach ($entryB in $PathB) {
228
+ if (-not (Test-EntryOverlap -EntryA $entryA -EntryB $entryB)) {
229
+ continue
230
+ }
231
+ $ordered = if ([string]::CompareOrdinal($entryA, $entryB) -le 0) {
232
+ @($entryA, $entryB)
233
+ } else {
234
+ @($entryB, $entryA)
235
+ }
236
+ $detail.Add($ordered -join $script:PairDetailSeparator)
237
+ }
238
+ }
239
+
240
+ return (Get-OrdinalSmallestEntry -Entry $detail.ToArray())
241
+ }
242
+
243
+ function Get-SmallestCommonEntry {
244
+ <#
245
+ .SYNOPSIS
246
+ Return the ordinally smallest entry present in both collections.
247
+
248
+ .DESCRIPTION
249
+ Port of _smallest_common. Two empty collections share nothing, so the
250
+ result is $null and the level contributes no reason.
251
+
252
+ .PARAMETER Left
253
+ First collection. An empty collection is accepted.
254
+
255
+ .PARAMETER Right
256
+ Second collection. An empty collection is accepted.
257
+
258
+ .OUTPUTS
259
+ System.String. The smallest common entry, or $null when there is none.
260
+ #>
261
+ [CmdletBinding()]
262
+ [OutputType([string])]
263
+ param(
264
+ [Parameter(Mandatory = $true)]
265
+ [AllowEmptyCollection()]
266
+ [AllowEmptyString()]
267
+ [string[]] $Left,
268
+ [Parameter(Mandatory = $true)]
269
+ [AllowEmptyCollection()]
270
+ [AllowEmptyString()]
271
+ [string[]] $Right
272
+ )
273
+
274
+ $rightSet = [System.Collections.Generic.HashSet[string]]::new($Right, [StringComparer]::Ordinal)
275
+ $common = [System.Collections.Generic.List[string]]::new()
276
+ foreach ($entry in $Left) {
277
+ if ($rightSet.Contains($entry)) {
278
+ $common.Add($entry)
279
+ }
280
+ }
281
+
282
+ return (Get-OrdinalSmallestEntry -Entry $common.ToArray())
283
+ }
284
+
285
+ Export-ModuleMember -Function `
286
+ Get-ConfigMergeablePath, `
287
+ Test-MergeablePath, `
288
+ Get-NonMergeablePathEntry, `
289
+ Get-SmallestPathOverlap, `
290
+ Get-SmallestCommonEntry