@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.
- package/out/mcp-server.js +639 -27
- package/package.json +1 -1
- package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +12 -6
- package/resources/claude-customizations/.claude/agents/powershell-typed-engineer.md +4 -5
- package/resources/claude-customizations/.claude/agents/python-typed-engineer.md +3 -3
- package/resources/claude-customizations/.claude/hooks/enforce-batch-budget-route.ps1 +139 -0
- package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate-resolution.ps1 +221 -0
- package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +42 -66
- package/resources/claude-customizations/.claude/hooks/enforce-epic-wave-barrier.ps1 +58 -10
- package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate-resolution.ps1 +144 -0
- package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +36 -47
- package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate-epic-scope.ps1 +179 -27
- package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +21 -38
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +4 -4
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-cohort-barrier.ps1 +58 -10
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-drift-gate.ps1 +59 -9
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +114 -37
- package/resources/claude-customizations/.claude/hooks/enforce-powershell-batch-budget.ps1 +95 -66
- package/resources/claude-customizations/.claude/hooks/enforce-python-batch-budget.ps1 +99 -64
- package/resources/claude-customizations/.claude/lib/bash/abandon-parallel-item.sh +176 -0
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConflict.psm1 +193 -14
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusGlob.psm1 +51 -28
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusScheduling.psm1 +5 -8
- package/resources/claude-customizations/.claude/lib/parallel-drift/Invoke-ParallelDriftDetection.ps1 +336 -0
- package/resources/claude-customizations/.claude/lib/parallel-drift/ParallelDrift.psm1 +467 -0
- package/resources/claude-customizations/.claude/lib/parallel-drift/ParallelDriftHalt.psm1 +388 -0
- package/resources/claude-customizations/.claude/lib/worktree-resolution/EpicScopeResolution.psm1 +53 -32
- package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeItemResolution.psm1 +19 -4
- package/resources/claude-customizations/.claude/lib/worktree-resolution/WorktreeRunResolution.psm1 +497 -0
- package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +21 -0
- package/resources/claude-customizations/.claude/rules/powershell.md +3 -3
- package/resources/claude-customizations/.claude/skills/epic-orchestrate/SKILL.md +2 -0
- package/resources/claude-customizations/.claude/skills/invoke-csharp-engineer/SKILL.md +4 -0
- package/resources/claude-customizations/.claude/skills/invoke-powershell-engineer/SKILL.md +7 -4
- package/resources/claude-customizations/.claude/skills/invoke-python-engineer/SKILL.md +6 -3
- package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +3 -1
- package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +32 -18
- package/resources/claude-customizations/.claude/skills/parallel-remove/SKILL.md +10 -10
- package/resources/claude-customizations/.claude/skills/powershell-change-budget-router/SKILL.md +6 -6
- package/resources/claude-customizations/.claude/skills/python-change-budget-router/SKILL.md +11 -35
- package/resources/claude-customizations/pack-manifests/core.json +8 -0
- package/resources/codex-and-agents-customizations/.agents/skills/invoke-powershell-engineer/SKILL.md +1 -2
- package/resources/codex-and-agents-customizations/.agents/skills/invoke-python-engineer/SKILL.md +2 -3
- package/resources/codex-and-agents-customizations/.agents/skills/powershell/SKILL.md +1 -2
- package/resources/codex-and-agents-customizations/.agents/skills/python-change-budget-router/SKILL.md +10 -34
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c1.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c2.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c3-elevated.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c3.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer-c4.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/powershell-typed-engineer.toml +2 -3
- package/resources/codex-and-agents-customizations/.codex/agents/python-orchestrator.toml +2 -2
- package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c1.toml +3 -3
- package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c2.toml +3 -3
- package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c3-elevated.toml +3 -3
- package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c3.toml +3 -3
- package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer-c4.toml +3 -3
- package/resources/codex-and-agents-customizations/.codex/agents/python-typed-engineer.toml +3 -3
- package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-batch-budget-route.ps1 +139 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-powershell-batch-budget.ps1 +165 -80
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-python-batch-budget.ps1 +169 -79
- package/resources/codex-and-agents-customizations/pack-manifests/core.json +1 -0
- package/resources/customizations/.github/agents/powershell-orchestrator.agent.md +7 -7
- package/resources/customizations/.github/agents/powershell-typed-engineer.agent.md +8 -10
- package/resources/customizations/.github/agents/python-orchestrator.agent.md +4 -4
- package/resources/customizations/.github/agents/python-typed-engineer.agent.md +7 -8
- package/resources/customizations/.github/prompts/orchestrate-powershell-work.prompt.md +2 -2
- package/resources/customizations/.github/prompts/orchestrate-python-work.prompt.md +3 -3
- package/resources/customizations/.github/skills/powershell-change-budget-router/SKILL.md +3 -3
- 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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
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:
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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 =
|
|
353
|
+
$prefixGlob = $EntryA.Substring(0, $indexA)
|
|
331
354
|
$directoryConcrete = $EntryB.TrimEnd('/') + '/'
|
|
332
|
-
return ((
|
|
333
|
-
$
|
|
334
|
-
|
|
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 =
|
|
360
|
+
$prefixGlob = $EntryB.Substring(0, $indexB)
|
|
338
361
|
$directoryConcrete = $EntryA.TrimEnd('/') + '/'
|
|
339
|
-
return ((
|
|
340
|
-
$
|
|
341
|
-
|
|
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 =
|
|
345
|
-
$prefixB =
|
|
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
|
|
265
|
-
#
|
|
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 ($
|
|
268
|
-
|
|
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)
|