@danmoisan/drm-copilot-mcp 1.1.15 → 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 (71) 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/powershell-typed-engineer.md +4 -5
  5. package/resources/claude-customizations/.claude/agents/python-typed-engineer.md +3 -3
  6. package/resources/claude-customizations/.claude/hooks/enforce-batch-budget-route.ps1 +139 -0
  7. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate-resolution.ps1 +221 -0
  8. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +42 -66
  9. package/resources/claude-customizations/.claude/hooks/enforce-epic-wave-barrier.ps1 +58 -10
  10. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate-resolution.ps1 +144 -0
  11. package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +36 -47
  12. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +179 -27
  13. package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +21 -38
  14. package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +4 -4
  15. package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier.ps1 +58 -10
  16. package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate.ps1 +59 -9
  17. package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +114 -37
  18. package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +95 -66
  19. package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +99 -64
  20. package/resources/claude-customizations/.claude/lib/bash/abandon-parallel-item.sh +176 -0
  21. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConflict.psm1 +193 -14
  22. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusGlob.psm1 +51 -28
  23. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusScheduling.psm1 +5 -8
  24. package/resources/claude-customizations/.claude/lib/parallel-drift/Invoke-ParallelDriftDetection.ps1 +336 -0
  25. package/resources/claude-customizations/.claude/lib/parallel-drift/ParallelDrift.psm1 +467 -0
  26. package/resources/claude-customizations/.claude/lib/parallel-drift/ParallelDriftHalt.psm1 +388 -0
  27. package/resources/claude-customizations/.claude/lib/worktree-resolution/EpicScopeResolution.psm1 +53 -32
  28. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeItemResolution.psm1 +19 -4
  29. package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeRunResolution.psm1 +497 -0
  30. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +21 -0
  31. package/resources/claude-customizations/.claude/rules/powershell.md +3 -3
  32. package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +2 -0
  33. package/resources/claude-customizations/.claude/skills/invoke-csharp-engineer/SKILL.md +4 -0
  34. package/resources/claude-customizations/.claude/skills/invoke-powershell-engineer/SKILL.md +7 -4
  35. package/resources/claude-customizations/.claude/skills/invoke-python-engineer/SKILL.md +6 -3
  36. package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +3 -1
  37. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +32 -18
  38. package/resources/claude-customizations/.claude/skills/parallel-remove/SKILL.md +10 -10
  39. package/resources/claude-customizations/.claude/skills/powershell-change-budget-router/SKILL.md +6 -6
  40. package/resources/claude-customizations/.claude/skills/python-change-budget-router/SKILL.md +11 -35
  41. package/resources/claude-customizations/pack-manifests/core.json +8 -0
  42. package/resources/codex-and-agents-customizations/.agents/skills/invoke-powershell-engineer/SKILL.md +1 -2
  43. package/resources/codex-and-agents-customizations/.agents/skills/invoke-python-engineer/SKILL.md +2 -3
  44. package/resources/codex-and-agents-customizations/.agents/skills/powershell/SKILL.md +1 -2
  45. package/resources/codex-and-agents-customizations/.agents/skills/python-change-budget-router/SKILL.md +10 -34
  46. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c1.toml +2 -3
  47. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c2.toml +2 -3
  48. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c3-elevated.toml +2 -3
  49. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c3.toml +2 -3
  50. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c4.toml +2 -3
  51. package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer.toml +2 -3
  52. package/resources/codex-and-agents-customizations/.codex/agents/python-orchestrator.toml +2 -2
  53. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c1.toml +3 -3
  54. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c2.toml +3 -3
  55. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c3-elevated.toml +3 -3
  56. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c3.toml +3 -3
  57. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c4.toml +3 -3
  58. package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer.toml +3 -3
  59. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  60. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-batch-budget-route.ps1 +139 -0
  61. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-powershell-batch-budget.ps1 +165 -80
  62. package/resources/codex-and-agents-customizations/.codex/hooks/enforce-python-batch-budget.ps1 +169 -79
  63. package/resources/codex-and-agents-customizations/pack-manifests/core.json +1 -0
  64. package/resources/customizations/.github/agents/powershell-orchestrator.agent.md +7 -7
  65. package/resources/customizations/.github/agents/powershell-typed-engineer.agent.md +8 -10
  66. package/resources/customizations/.github/agents/python-orchestrator.agent.md +4 -4
  67. package/resources/customizations/.github/agents/python-typed-engineer.agent.md +7 -8
  68. package/resources/customizations/.github/prompts/orchestrate-powershell-work.prompt.md +2 -2
  69. package/resources/customizations/.github/prompts/orchestrate-python-work.prompt.md +3 -3
  70. package/resources/customizations/.github/skills/powershell-change-budget-router/SKILL.md +3 -3
  71. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +12 -0
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env bash
2
+ # abandon-parallel-item.sh: bundled destination-runtime port of
3
+ # scripts/dev_tools/parallel_mutation_abandon_cli.py (issue #763). It executes the
4
+ # abandon disposition of /parallel-remove -- close the item's pull request, then
5
+ # remove its worktree -- with nothing but bash, gh, and git on PATH: no Python, no
6
+ # Poetry, no repository checkout.
7
+ #
8
+ # Usage:
9
+ # bash .claude/lib/bash/abandon-parallel-item.sh --item <key> --disposition abandon --confirm-abandon --pr <pr-number> --worktree <worktree-path>
10
+ #
11
+ # The PreToolUse hook .claude/hooks/enforce-parallel-abandon-gate.ps1 matches the
12
+ # command text of that invocation: a command carrying the disposition token
13
+ # without the confirmation marker is denied before it runs. The option tokens are
14
+ # declared once each below, and tests/scripts/dev_tools/test_parallel_abandon_token_seam.py
15
+ # binds them to the Python reference and to the hook.
16
+ #
17
+ # Exit codes:
18
+ # 0 success; both side effects ran and nothing was written to stdout
19
+ # 1 a side effect failed; the first failure is reported and nothing after it runs
20
+ # 2 refusal (a disposition other than abandon, or no confirmation marker) or a
21
+ # usage error; both are reported before any side effect
22
+ #
23
+ # Declared divergences from the Python reference, all within "usage errors exit 2":
24
+ # - argparse usage text is not reproduced; only exit code 2 is matched, with one
25
+ # stderr line prefixed "PARALLEL_ABANDON_ERROR: usage error:".
26
+ # - option abbreviations are rejected (argparse accepts a unique prefix).
27
+ # - --item and --pr accept only canonical decimal integers (0 or [1-9][0-9]*).
28
+ # - -h and --help are unknown options.
29
+ # - an option value beginning with -- is treated as a missing value.
30
+ # Repeated options keep the last value and the joined --name=value form is
31
+ # accepted, as argparse does.
32
+ set -euo pipefail
33
+
34
+ # The option tokens and messages the abandon surface is built from. The first
35
+ # three lines are read by the seam test and by tests/shell/parallel_abandon.bats.
36
+ readonly ABANDON_DISPOSITION_OPTION='--disposition'
37
+ readonly ABANDON_DISPOSITION_VALUE='abandon'
38
+ readonly ABANDON_CONFIRM_OPTION='--confirm-abandon'
39
+ readonly ABANDON_ITEM_OPTION='--item'
40
+ readonly ABANDON_PR_OPTION='--pr'
41
+ readonly ABANDON_WORKTREE_OPTION='--worktree'
42
+ readonly ABANDON_VALID_DISPOSITIONS='detach abandon'
43
+ readonly ABANDON_ERROR_PREFIX='PARALLEL_ABANDON_ERROR:'
44
+
45
+ abandon_usage_error() {
46
+ # Report a usage error on stderr and exit 2 before any side effect.
47
+ #
48
+ # Args: $1 = the detail naming the offending argument.
49
+ printf '%s usage error: %s\n' "$ABANDON_ERROR_PREFIX" "$1" >&2
50
+ exit 2
51
+ }
52
+
53
+ abandon_require_integer() {
54
+ # Require a canonical decimal integer, as the reference's type=int requires
55
+ # an integer; leading zeros, signs, and blanks are usage errors here.
56
+ #
57
+ # Args: $1 = the value, $2 = the option it was given for.
58
+ if [[ ! $1 =~ ^(0|[1-9][0-9]*)$ ]]; then
59
+ abandon_usage_error "$2 requires a canonical decimal integer; got '$1'"
60
+ fi
61
+ }
62
+
63
+ abandon_require_disposition() {
64
+ # Require a member of the disposition vocabulary, as the reference's
65
+ # argparse choices do. Membership is not the abandon refusal: a valid
66
+ # disposition other than abandon is refused later with its own message.
67
+ #
68
+ # Args: $1 = the disposition value.
69
+ local candidate
70
+ # Scan the space-separated vocabulary for an exact match.
71
+ for candidate in $ABANDON_VALID_DISPOSITIONS; do
72
+ if [ "$candidate" = "$1" ]; then
73
+ return 0
74
+ fi
75
+ done
76
+ abandon_usage_error "$ABANDON_DISPOSITION_OPTION must be one of: $ABANDON_VALID_DISPOSITIONS; got '$1'"
77
+ }
78
+
79
+ abandon_side_effect_failed() {
80
+ # Report a failed side effect and exit 1, so nothing after it runs.
81
+ #
82
+ # Args: $1 = the exit code (-1 when the executable is not on PATH), then the
83
+ # command vector, which is rendered joined by single spaces.
84
+ local code="$1"
85
+ shift
86
+ printf '%s abandon side effect failed with exit code %s: %s\n' "$ABANDON_ERROR_PREFIX" "$code" "$*" >&2
87
+ exit 1
88
+ }
89
+
90
+ abandon_run_side_effect() {
91
+ # Run one side-effect command resolved through PATH. A non-zero exit is
92
+ # captured rather than aborting under set -e, so it can be reported.
93
+ #
94
+ # Args: the command vector, executable name first.
95
+ local executable status=0
96
+ if ! executable="$(command -v -- "$1")"; then
97
+ abandon_side_effect_failed -1 "$@"
98
+ fi
99
+ "$executable" "${@:2}" || status=$?
100
+ if [ "$status" -ne 0 ]; then
101
+ abandon_side_effect_failed "$status" "$@"
102
+ fi
103
+ }
104
+
105
+ item=''
106
+ disposition=''
107
+ pr=''
108
+ worktree=''
109
+ confirmed=0
110
+ have_item=0
111
+ have_disposition=0
112
+ have_pr=0
113
+ have_worktree=0
114
+
115
+ # Parse the argument vector. The bare confirmation flag sets the marker; each of
116
+ # the four valued options takes its value joined (--name=value) or as the next
117
+ # argument; anything else -- an unknown option, an abbreviation, a value on the
118
+ # confirmation flag, or a positional argument -- is a usage error.
119
+ while [ "$#" -gt 0 ]; do
120
+ argument="$1"
121
+ shift
122
+ if [ "$argument" = "$ABANDON_CONFIRM_OPTION" ]; then
123
+ confirmed=1
124
+ continue
125
+ fi
126
+ name="${argument%%=*}"
127
+ case "$name" in
128
+ "$ABANDON_ITEM_OPTION" | "$ABANDON_DISPOSITION_OPTION" | "$ABANDON_PR_OPTION" | "$ABANDON_WORKTREE_OPTION") ;;
129
+ *) abandon_usage_error "unrecognized argument: $argument" ;;
130
+ esac
131
+ # Take the joined value when present, otherwise the next argument; an absent
132
+ # value is represented by the option prefix so one check rejects both forms.
133
+ if [ "$name" != "$argument" ]; then
134
+ value="${argument#*=}"
135
+ elif [ "$#" -gt 0 ]; then
136
+ value="$1"
137
+ shift
138
+ else
139
+ value='--'
140
+ fi
141
+ if [[ $value == --* ]]; then
142
+ abandon_usage_error "$name requires a value"
143
+ fi
144
+ # Store the value on its option; a repeated option keeps the last value.
145
+ case "$name" in
146
+ "$ABANDON_ITEM_OPTION") item="$value" have_item=1 ;;
147
+ "$ABANDON_DISPOSITION_OPTION") disposition="$value" have_disposition=1 ;;
148
+ "$ABANDON_PR_OPTION") pr="$value" have_pr=1 ;;
149
+ *) worktree="$value" have_worktree=1 ;;
150
+ esac
151
+ done
152
+
153
+ # Every valued option is required, as in the reference parser.
154
+ if [ "$have_item" -eq 0 ] || [ "$have_disposition" -eq 0 ] || [ "$have_pr" -eq 0 ] || [ "$have_worktree" -eq 0 ]; then
155
+ abandon_usage_error "the options $ABANDON_ITEM_OPTION, $ABANDON_DISPOSITION_OPTION, $ABANDON_PR_OPTION, and $ABANDON_WORKTREE_OPTION are required"
156
+ fi
157
+ abandon_require_integer "$item" "$ABANDON_ITEM_OPTION"
158
+ abandon_require_integer "$pr" "$ABANDON_PR_OPTION"
159
+ abandon_require_disposition "$disposition"
160
+
161
+ # The two refusals come before any side effect: this entry point exists only to
162
+ # execute the confirmed abandon path, so any other request does nothing.
163
+ if [ "$disposition" != "$ABANDON_DISPOSITION_VALUE" ]; then
164
+ printf "%s this CLI executes the '%s' disposition only; got '%s'.\n" "$ABANDON_ERROR_PREFIX" "$ABANDON_DISPOSITION_VALUE" "$disposition" >&2
165
+ exit 2
166
+ fi
167
+ if [ "$confirmed" -ne 1 ]; then
168
+ printf '%s refusing to abandon item %s without the explicit %s confirmation marker; no side effect was performed.\n' "$ABANDON_ERROR_PREFIX" "$item" "$ABANDON_CONFIRM_OPTION" >&2
169
+ exit 2
170
+ fi
171
+
172
+ # Close the pull request before removing the worktree: removing the worktree
173
+ # first would leave an open pull request whose checkout is gone.
174
+ abandon_run_side_effect gh pr close "$pr"
175
+ abandon_run_side_effect git worktree remove "$worktree"
176
+ exit 0
@@ -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)