@danmoisan/drm-copilot-mcp 1.1.16 → 1.1.17

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 (58) hide show
  1. package/out/mcp-server.js +639 -27
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +12 -6
  4. package/resources/claude-customizations/.claude/agents/python-typed-engineer.md +3 -3
  5. package/resources/claude-customizations/.claude/hooks/{enforce-powershell-batch-budget-route.ps1 → enforce-batch-budget-route.ps1} +12 -10
  6. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate-resolution.ps1 +221 -0
  7. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +42 -66
  8. package/resources/claude-customizations/.claude/hooks/enforce-epic-wave-barrier.ps1 +58 -10
  9. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate-resolution.ps1 +144 -0
  10. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +36 -47
  11. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +179 -27
  12. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +21 -38
  13. package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +4 -4
  14. package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier.ps1 +58 -10
  15. package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate.ps1 +59 -9
  16. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +114 -37
  17. package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +3 -3
  18. package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +99 -64
  19. package/resources/claude-customizations/.claude/lib/bash/abandon-parallel-item.sh +176 -0
  20. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConflict.psm1 +193 -14
  21. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusGlob.psm1 +51 -28
  22. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusScheduling.psm1 +5 -8
  23. package/resources/claude-customizations/.claude/lib/parallel-drift/Invoke-ParallelDriftDetection.ps1 +336 -0
  24. package/resources/claude-customizations/.claude/lib/parallel-drift/ParallelDrift.psm1 +467 -0
  25. package/resources/claude-customizations/.claude/lib/parallel-drift/ParallelDriftHalt.psm1 +388 -0
  26. package/resources/claude-customizations/.claude/lib/worktree-resolution/EpicScopeResolution.psm1 +53 -32
  27. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeItemResolution.psm1 +19 -4
  28. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeRunResolution.psm1 +497 -0
  29. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +21 -0
  30. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +2 -0
  31. package/resources/claude-customizations/.claude/skills/invoke-csharp-engineer/SKILL.md +4 -0
  32. package/resources/claude-customizations/.claude/skills/invoke-powershell-engineer/SKILL.md +4 -0
  33. package/resources/claude-customizations/.claude/skills/invoke-python-engineer/SKILL.md +6 -3
  34. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +3 -1
  35. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +32 -18
  36. package/resources/claude-customizations/.claude/skills/parallel-remove/SKILL.md +10 -10
  37. package/resources/claude-customizations/.claude/skills/python-change-budget-router/SKILL.md +11 -35
  38. package/resources/claude-customizations/pack-manifests/core.json +8 -0
  39. package/resources/claude-customizations/pack-manifests/powershell.json +0 -1
  40. package/resources/codex-and-agents-customizations/.agents/skills/invoke-powershell-engineer/SKILL.md +0 -1
  41. package/resources/codex-and-agents-customizations/.agents/skills/invoke-python-engineer/SKILL.md +2 -3
  42. package/resources/codex-and-agents-customizations/.agents/skills/python-change-budget-router/SKILL.md +10 -34
  43. package/resources/codex-and-agents-customizations/.codex/agents/python-orchestrator.toml +2 -2
  44. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c1.toml +3 -3
  45. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c2.toml +3 -3
  46. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c3-elevated.toml +3 -3
  47. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c3.toml +3 -3
  48. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c4.toml +3 -3
  49. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer.toml +3 -3
  50. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  51. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-batch-budget-route.ps1 +139 -0
  52. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-powershell-batch-budget.ps1 +4 -124
  53. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-python-batch-budget.ps1 +169 -79
  54. package/resources/codex-and-agents-customizations/pack-manifests/core.json +1 -0
  55. package/resources/customizations/.github/agents/python-orchestrator.agent.md +4 -4
  56. package/resources/customizations/.github/agents/python-typed-engineer.agent.md +7 -8
  57. package/resources/customizations/.github/prompts/orchestrate-python-work.prompt.md +3 -3
  58. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +12 -1
@@ -190,6 +190,182 @@ function Get-NonMergeablePathEntry {
190
190
  return @(Get-OrdinalSortedEntry -Entry $survivor.ToArray())
191
191
  }
192
192
 
193
+ function ConvertTo-PathOverlapRecord {
194
+ <#
195
+ .SYNOPSIS
196
+ Precompute the per-entry facts the pairwise overlap decision reads.
197
+
198
+ .DESCRIPTION
199
+ Called once per path collection by Get-OverlappingPathPair (issue #776),
200
+ so the advanced-function calls made here are linear in the collection
201
+ size rather than quadratic in the number of pairs.
202
+
203
+ .PARAMETER Entry
204
+ Radius path entries. An empty collection and empty strings are accepted.
205
+
206
+ .OUTPUTS
207
+ System.Object[]. One hashtable record per entry, in input order, with
208
+ keys Entry, IsGlob, Prefix (the literal prefix of a glob entry, $null for
209
+ a concrete entry), and Directory (the entry anchored with one trailing
210
+ separator).
211
+ #>
212
+ [CmdletBinding()]
213
+ [OutputType([System.Object[]])]
214
+ param(
215
+ [Parameter(Mandatory = $true)]
216
+ [AllowEmptyCollection()]
217
+ [AllowEmptyString()]
218
+ [string[]] $Entry
219
+ )
220
+
221
+ $record = [System.Collections.Generic.List[hashtable]]::new()
222
+ foreach ($single in $Entry) {
223
+ $isGlob = Test-GlobEntry -Entry $single
224
+ $prefix = if ($isGlob) { Get-LiteralPrefix -Entry $single } else { $null }
225
+ $record.Add(@{
226
+ Entry = $single
227
+ IsGlob = $isGlob
228
+ Prefix = $prefix
229
+ Directory = $single.TrimEnd('/') + '/'
230
+ })
231
+ }
232
+
233
+ return @($record.ToArray())
234
+ }
235
+
236
+ # The one record-form overlap decision (issue #776); its four cases reproduce
237
+ # Test-EntryOverlap exactly (parity guard: BlastRadiusConflict.PathOverlap.Tests.ps1).
238
+ # A simple function because it runs per candidate pair (see Get-GlobRegex).
239
+ function Test-PathOverlapRecordPair {
240
+ param([hashtable] $Left, [hashtable] $Right)
241
+
242
+ $ordinal = [System.StringComparison]::Ordinal
243
+ if (-not $Left.IsGlob -and -not $Right.IsGlob) {
244
+ # Two concrete entries: equality or either anchored directory prefix.
245
+ return ([string]::Equals($Left.Entry, $Right.Entry, $ordinal) -or
246
+ $Left.Entry.StartsWith($Right.Directory, $ordinal) -or
247
+ $Right.Entry.StartsWith($Left.Directory, $ordinal))
248
+ }
249
+ if ($Left.IsGlob -and -not $Right.IsGlob) {
250
+ # Glob and concrete: the two literal nest tests, then the pattern match.
251
+ return ($Left.Prefix.StartsWith($Right.Directory, $ordinal) -or
252
+ $Right.Directory.StartsWith($Left.Prefix, $ordinal) -or
253
+ (Test-GlobMatch -Pattern $Left.Entry -Candidate $Right.Entry))
254
+ }
255
+ if ($Right.IsGlob -and -not $Left.IsGlob) {
256
+ return ($Right.Prefix.StartsWith($Left.Directory, $ordinal) -or
257
+ $Left.Directory.StartsWith($Right.Prefix, $ordinal) -or
258
+ (Test-GlobMatch -Pattern $Right.Entry -Candidate $Left.Entry))
259
+ }
260
+ # Two globs: either literal prefix nests the other.
261
+ return ($Left.Prefix.StartsWith($Right.Prefix, $ordinal) -or
262
+ $Right.Prefix.StartsWith($Left.Prefix, $ordinal))
263
+ }
264
+
265
+ function Get-OverlappingPathPair {
266
+ <#
267
+ .SYNOPSIS
268
+ Return every overlapping path pair of two collections, in nested-loop order.
269
+
270
+ .DESCRIPTION
271
+ Yields exactly the pairs for which Test-EntryOverlap holds without
272
+ evaluating all |PathA| x |PathB| pairs (issue #776). Concrete pairs, almost
273
+ all pairs in practice, are found by dictionary lookup: equality against
274
+ the concrete entries of the other side, and anchored-directory
275
+ containment, because X.StartsWith(Y.TrimEnd('/') + '/') holds exactly
276
+ when X[k] is '/' and X.Substring(0, k) equals Y.TrimEnd('/') for some k
277
+ over the positions of '/' in X, so one lookup per separator position of X
278
+ decides it (probed in both directions). Every glob-involving pair is
279
+ decided by Test-PathOverlapRecordPair against the other collection.
280
+
281
+ Ordering contract: each pair is recorded once under the key
282
+ i * |PathB| + j and emitted in ascending key order, which is nested-loop
283
+ order (outer over PathA, inner over PathB, both in input order). Pairs are
284
+ never reordered by value; a repeated entry yields one pair per occurrence.
285
+
286
+ .PARAMETER PathA
287
+ First path collection. An empty collection and empty strings are accepted.
288
+
289
+ .PARAMETER PathB
290
+ Second path collection. An empty collection and empty strings are accepted.
291
+
292
+ .OUTPUTS
293
+ System.Object[]. One hashtable per overlapping pair with keys EntryA (from
294
+ PathA) and EntryB (from PathB); an empty array when nothing overlaps.
295
+ #>
296
+ [CmdletBinding()]
297
+ [OutputType([System.Object[]])]
298
+ param(
299
+ [Parameter(Mandatory = $true)]
300
+ [AllowEmptyCollection()]
301
+ [AllowEmptyString()]
302
+ [string[]] $PathA,
303
+ [Parameter(Mandatory = $true)]
304
+ [AllowEmptyCollection()]
305
+ [AllowEmptyString()]
306
+ [string[]] $PathB
307
+ )
308
+
309
+ $recordA = @(ConvertTo-PathOverlapRecord -Entry $PathA)
310
+ $recordB = @(ConvertTo-PathOverlapRecord -Entry $PathB)
311
+ $countB = [long]$recordB.Count
312
+ $pairKey = [System.Collections.Generic.HashSet[long]]::new()
313
+ $hit = $null
314
+
315
+ # Concrete entries keyed verbatim (equality) and trimmed of trailing separators
316
+ # (containment); each key lists every index carrying it, one per occurrence.
317
+ $exactB, $trimmedB, $trimmedA = @(1..3 | ForEach-Object { [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[int]]]::new([StringComparer]::Ordinal) })
318
+ for ($j = 0; $j -lt $recordB.Count; $j++) {
319
+ if ($recordB[$j].IsGlob) { continue }
320
+ foreach ($target in @(@($exactB, $recordB[$j].Entry), @($trimmedB, $recordB[$j].Entry.TrimEnd('/')))) {
321
+ if (-not $target[0].ContainsKey($target[1])) { $target[0][$target[1]] = [System.Collections.Generic.List[int]]::new() }
322
+ $target[0][$target[1]].Add($j)
323
+ }
324
+ }
325
+
326
+ for ($i = 0; $i -lt $recordA.Count; $i++) {
327
+ $entry = $recordA[$i].Entry
328
+ if ($recordA[$i].IsGlob) {
329
+ # A PathA glob is decided against every PathB record.
330
+ for ($j = 0; $j -lt $recordB.Count; $j++) {
331
+ if (Test-PathOverlapRecordPair -Left $recordA[$i] -Right $recordB[$j]) { [void]$pairKey.Add($i * $countB + $j) }
332
+ }
333
+ continue
334
+ }
335
+ if (-not $trimmedA.ContainsKey($entry.TrimEnd('/'))) { $trimmedA[$entry.TrimEnd('/')] = [System.Collections.Generic.List[int]]::new() }
336
+ $trimmedA[$entry.TrimEnd('/')].Add($i)
337
+ # Equality, then containment at each separator (the PathB pass probes the reverse).
338
+ if ($exactB.TryGetValue($entry, [ref]$hit)) { foreach ($j in $hit) { [void]$pairKey.Add($i * $countB + $j) } }
339
+ for ($slash = $entry.IndexOf('/'); $slash -ge 0; $slash = $entry.IndexOf('/', $slash + 1)) {
340
+ if ($trimmedB.TryGetValue($entry.Substring(0, $slash), [ref]$hit)) { foreach ($j in $hit) { [void]$pairKey.Add($i * $countB + $j) } }
341
+ }
342
+ }
343
+
344
+ for ($j = 0; $j -lt $recordB.Count; $j++) {
345
+ $entry = $recordB[$j].Entry
346
+ if ($recordB[$j].IsGlob) {
347
+ # Against concrete PathA records only; glob pairs were decided above.
348
+ for ($i = 0; $i -lt $recordA.Count; $i++) {
349
+ if (-not $recordA[$i].IsGlob -and (Test-PathOverlapRecordPair -Left $recordA[$i] -Right $recordB[$j])) { [void]$pairKey.Add($i * $countB + $j) }
350
+ }
351
+ continue
352
+ }
353
+ for ($slash = $entry.IndexOf('/'); $slash -ge 0; $slash = $entry.IndexOf('/', $slash + 1)) {
354
+ if ($trimmedA.TryGetValue($entry.Substring(0, $slash), [ref]$hit)) { foreach ($i in $hit) { [void]$pairKey.Add($i * $countB + $j) } }
355
+ }
356
+ }
357
+
358
+ $sortedKey = [long[]]@($pairKey)
359
+ [System.Array]::Sort($sortedKey)
360
+ $pair = [System.Collections.Generic.List[hashtable]]::new()
361
+ foreach ($key in $sortedKey) {
362
+ $i = [int][System.Math]::Floor($key / $countB)
363
+ $pair.Add(@{ EntryA = $recordA[$i].Entry; EntryB = $recordB[[int]($key - $i * $countB)].Entry })
364
+ }
365
+
366
+ return @($pair.ToArray())
367
+ }
368
+
193
369
  function Get-SmallestPathOverlap {
194
370
  <#
195
371
  .SYNOPSIS
@@ -222,22 +398,24 @@ function Get-SmallestPathOverlap {
222
398
  [string[]] $PathB
223
399
  )
224
400
 
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)
401
+ # The overlapping pairs come from the shared enumeration, so the record-form
402
+ # decision exists once (issue #776). The minimum over the ordered details does
403
+ # not depend on the order in which the pairs are enumerated.
404
+ $smallest = $null
405
+ foreach ($pair in @(Get-OverlappingPathPair -PathA $PathA -PathB $PathB)) {
406
+ # Order the pair ordinally so the detail is identical in both argument
407
+ # orders, then keep it only when it is the new ordinal minimum.
408
+ $pairDetail = if ([string]::CompareOrdinal($pair['EntryA'], $pair['EntryB']) -le 0) {
409
+ $pair['EntryA'] + $script:PairDetailSeparator + $pair['EntryB']
410
+ } else {
411
+ $pair['EntryB'] + $script:PairDetailSeparator + $pair['EntryA']
412
+ }
413
+ if ($null -eq $smallest -or [string]::CompareOrdinal($pairDetail, $smallest) -lt 0) {
414
+ $smallest = $pairDetail
237
415
  }
238
416
  }
239
417
 
240
- return (Get-OrdinalSmallestEntry -Entry $detail.ToArray())
418
+ return $smallest
241
419
  }
242
420
 
243
421
  function Get-SmallestCommonEntry {
@@ -287,4 +465,5 @@ Export-ModuleMember -Function `
287
465
  Test-MergeablePath, `
288
466
  Get-NonMergeablePathEntry, `
289
467
  Get-SmallestPathOverlap, `
290
- Get-SmallestCommonEntry
468
+ Get-SmallestCommonEntry, `
469
+ Get-OverlappingPathPair
@@ -31,6 +31,9 @@
31
31
  newline in .NET.
32
32
  - Comparisons and ordering use [StringComparer]::Ordinal and
33
33
  [string]::CompareOrdinal so results do not vary with the current culture.
34
+ - Test-GlobMatch memoizes each translated pattern in a script-scoped,
35
+ ordinally keyed regex cache (issue #776). The translation is pure, so
36
+ the cache changes speed only; Python recompiles through re's own cache.
34
37
  CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop.
35
38
  #>
36
39
 
@@ -40,7 +43,12 @@ $ErrorActionPreference = 'Stop'
40
43
  # Wildcards that make a path entry a pattern rather than a file. The question
41
44
  # mark is included because the subsumption helper treats it as a pattern;
42
45
  # admitting it here keeps every comparison in the fail-closed direction.
43
- $script:GlobWildcard = @('*', '?')
46
+ $script:GlobWildcardCharacter = [char[]]@('*', '?')
47
+
48
+ # Compiled whole-string regex per glob pattern (issue #776). Keys compare
49
+ # ordinally so patterns differing only by case stay distinct. The cache lives
50
+ # per module instance and is bounded by the number of distinct patterns seen.
51
+ $script:GlobRegexCache = [System.Collections.Generic.Dictionary[string, regex]]::new([StringComparer]::Ordinal)
44
52
 
45
53
 
46
54
  function Test-GlobEntry {
@@ -49,8 +57,8 @@ function Test-GlobEntry {
49
57
  Report whether a path entry is a wildcard pattern rather than a file.
50
58
 
51
59
  .DESCRIPTION
52
- Port of is_glob_entry. Used both to classify radius path entries and, one
53
- character at a time, by the literal-prefix helper.
60
+ Port of is_glob_entry. Used to classify radius path entries; a single
61
+ ordinal IndexOfAny scan over the wildcard set decides the answer.
54
62
 
55
63
  .PARAMETER Entry
56
64
  A paths entry from a radius or an extraction, or a single character.
@@ -66,13 +74,7 @@ function Test-GlobEntry {
66
74
  [string] $Entry
67
75
  )
68
76
 
69
- foreach ($wildcard in $script:GlobWildcard) {
70
- if ($Entry.IndexOf($wildcard, [System.StringComparison]::Ordinal) -ge 0) {
71
- return $true
72
- }
73
- }
74
-
75
- return $false
77
+ return $Entry.IndexOfAny($script:GlobWildcardCharacter) -ge 0
76
78
  }
77
79
 
78
80
  function Get-ConcreteEntry {
@@ -147,6 +149,23 @@ function ConvertTo-GlobRegexText {
147
149
  return $part.ToString()
148
150
  }
149
151
 
152
+ # Return the cached anchored regex for a glob pattern, building it on a miss.
153
+ # Declared as a simple function, without [CmdletBinding()], because it runs once
154
+ # per Test-GlobMatch call and advanced-function parameter binding is the
155
+ # per-call cost issue #776 removes from the pairwise overlap hot path.
156
+ function Get-GlobRegex {
157
+ param([string] $Pattern)
158
+
159
+ $cached = $null
160
+ if ($script:GlobRegexCache.TryGetValue($Pattern, [ref]$cached)) {
161
+ return $cached
162
+ }
163
+
164
+ $compiled = [regex]::new('\A(?:' + (ConvertTo-GlobRegexText -Pattern $Pattern) + ')\z')
165
+ $script:GlobRegexCache[$Pattern] = $compiled
166
+ return $compiled
167
+ }
168
+
150
169
  function Test-GlobMatch {
151
170
  <#
152
171
  .SYNOPSIS
@@ -176,8 +195,7 @@ function Test-GlobMatch {
176
195
  [string] $Candidate
177
196
  )
178
197
 
179
- $regexText = '\A(?:' + (ConvertTo-GlobRegexText -Pattern $Pattern) + ')\z'
180
- return [regex]::IsMatch($Candidate, $regexText)
198
+ return (Get-GlobRegex -Pattern $Pattern).IsMatch($Candidate)
181
199
  }
182
200
 
183
201
  function Test-PathSubsumed {
@@ -261,10 +279,9 @@ function Get-LiteralPrefix {
261
279
  [string] $Entry
262
280
  )
263
281
 
264
- for ($index = 0; $index -lt $Entry.Length; $index++) {
265
- if (Test-GlobEntry -Entry ([string]$Entry[$index])) {
266
- return $Entry.Substring(0, $index)
267
- }
282
+ $index = $Entry.IndexOfAny($script:GlobWildcardCharacter)
283
+ if ($index -ge 0) {
284
+ return $Entry.Substring(0, $index)
268
285
  }
269
286
 
270
287
  return $Entry
@@ -308,8 +325,12 @@ function Test-EntryOverlap {
308
325
  [string] $EntryB
309
326
  )
310
327
 
311
- $aIsGlob = Test-GlobEntry -Entry $EntryA
312
- $bIsGlob = Test-GlobEntry -Entry $EntryB
328
+ # One ordinal scan per entry yields both the glob flag and the literal prefix,
329
+ # so this hot path makes no advanced-function call for either (issue #776).
330
+ $indexA = $EntryA.IndexOfAny($script:GlobWildcardCharacter)
331
+ $indexB = $EntryB.IndexOfAny($script:GlobWildcardCharacter)
332
+ $aIsGlob = $indexA -ge 0
333
+ $bIsGlob = $indexB -ge 0
313
334
 
314
335
  if (-not $aIsGlob -and -not $bIsGlob) {
315
336
  # Anchoring each entry with a trailing separator before the prefix test is
@@ -326,23 +347,25 @@ function Test-EntryOverlap {
326
347
  # entry's directory prefix nest. The nest is tested in both directions because
327
348
  # the glob may be rooted above the directory (scripts/ above
328
349
  # scripts/dev_tools/) or below it, and either arrangement admits a common file.
350
+ # The two literal nest tests run before the pattern match: all three terms are
351
+ # pure, so the order of the OR changes cost only, never the verdict.
329
352
  if ($aIsGlob -and -not $bIsGlob) {
330
- $prefixGlob = Get-LiteralPrefix -Entry $EntryA
353
+ $prefixGlob = $EntryA.Substring(0, $indexA)
331
354
  $directoryConcrete = $EntryB.TrimEnd('/') + '/'
332
- return ((Test-GlobMatch -Pattern $EntryA -Candidate $EntryB) -or
333
- $prefixGlob.StartsWith($directoryConcrete, [System.StringComparison]::Ordinal) -or
334
- $directoryConcrete.StartsWith($prefixGlob, [System.StringComparison]::Ordinal))
355
+ return ($prefixGlob.StartsWith($directoryConcrete, [System.StringComparison]::Ordinal) -or
356
+ $directoryConcrete.StartsWith($prefixGlob, [System.StringComparison]::Ordinal) -or
357
+ (Test-GlobMatch -Pattern $EntryA -Candidate $EntryB))
335
358
  }
336
359
  if ($bIsGlob -and -not $aIsGlob) {
337
- $prefixGlob = Get-LiteralPrefix -Entry $EntryB
360
+ $prefixGlob = $EntryB.Substring(0, $indexB)
338
361
  $directoryConcrete = $EntryA.TrimEnd('/') + '/'
339
- return ((Test-GlobMatch -Pattern $EntryB -Candidate $EntryA) -or
340
- $prefixGlob.StartsWith($directoryConcrete, [System.StringComparison]::Ordinal) -or
341
- $directoryConcrete.StartsWith($prefixGlob, [System.StringComparison]::Ordinal))
362
+ return ($prefixGlob.StartsWith($directoryConcrete, [System.StringComparison]::Ordinal) -or
363
+ $directoryConcrete.StartsWith($prefixGlob, [System.StringComparison]::Ordinal) -or
364
+ (Test-GlobMatch -Pattern $EntryB -Candidate $EntryA))
342
365
  }
343
366
 
344
- $prefixA = Get-LiteralPrefix -Entry $EntryA
345
- $prefixB = Get-LiteralPrefix -Entry $EntryB
367
+ $prefixA = $EntryA.Substring(0, $indexA)
368
+ $prefixB = $EntryB.Substring(0, $indexB)
346
369
  return ($prefixA.StartsWith($prefixB, [System.StringComparison]::Ordinal) -or
347
370
  $prefixB.StartsWith($prefixA, [System.StringComparison]::Ordinal))
348
371
  }
@@ -261,15 +261,12 @@ function Get-BlastRadiusPairCost {
261
261
  $pathsA = @(Get-NonMergeablePathEntry -Entry ([string[]]@($left['paths'])) -MergeablePath $mergeable)
262
262
  $pathsB = @(Get-NonMergeablePathEntry -Entry ([string[]]@($right['paths'])) -MergeablePath $mergeable)
263
263
 
264
- # Enumerate the overlapping pairs with the relation's own primitive, so the
265
- # cost covers exactly the pairs the relation could report.
264
+ # Enumerate the overlapping pairs with the relation's own indexed enumeration
265
+ # (the Test-EntryOverlap pairs, issue #776), so the cost covers exactly the
266
+ # pairs the relation could report.
266
267
  [long]$cost = 0
267
- foreach ($entryA in $pathsA) {
268
- foreach ($entryB in $pathsB) {
269
- if (Test-EntryOverlap -EntryA $entryA -EntryB $entryB) {
270
- $cost += Get-PathPairWeight -EntryA $entryA -EntryB $entryB -Tolerance $Tolerance
271
- }
272
- }
268
+ foreach ($pair in @(Get-OverlappingPathPair -PathA $pathsA -PathB $pathsB)) {
269
+ $cost += Get-PathPairWeight -EntryA $pair['EntryA'] -EntryB $pair['EntryB'] -Tolerance $Tolerance
273
270
  }
274
271
  $sharedModule = @(@($left['modules']) | Where-Object { @($right['modules']) -ccontains $_ })
275
272
  return $cost + ([long]$Tolerance['weights']['module'] * $sharedModule.Count)