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