@danmoisan/drm-copilot-mcp 1.1.9 → 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 +3526 -1037
- 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 +254 -5
- 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,12 +186,139 @@ 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
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return $null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# File-reading commands Claude Code's Bash permission engine resolves against
|
|
215
|
+
# Read() rules rather than Bash() prefix rules. Measured empirically (2026-09):
|
|
216
|
+
# once one of these co-occurs with a preceding `cd` in the same command line
|
|
217
|
+
# (chained via `&&` or `;`), the engine always requires manual approval -
|
|
218
|
+
# regardless of any Read() or Bash() allow rule present, and regardless of
|
|
219
|
+
# whether the read command's own path argument is relative or absolute. No
|
|
220
|
+
# settings.json configuration can suppress that prompt; the only fix is to
|
|
221
|
+
# never chain `cd` with one of these in the same command.
|
|
222
|
+
$script:CdChainedReadCommandPattern = 'cd\s+\S.*?(&&|;)\s*(grep|cat|head|tail|less|more|awk|sed\s+-n)\b'
|
|
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
|
+
|
|
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
|
+
#>
|
|
268
|
+
[CmdletBinding()]
|
|
269
|
+
[OutputType([string])]
|
|
270
|
+
param(
|
|
271
|
+
[Parameter(Mandatory = $false)]
|
|
272
|
+
[AllowEmptyString()]
|
|
273
|
+
[AllowNull()]
|
|
274
|
+
[string] $Command
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if (-not $Command) {
|
|
278
|
+
return $null
|
|
279
|
+
}
|
|
280
|
+
|
|
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+', ' ')
|
|
75
294
|
}
|
|
76
295
|
}
|
|
77
296
|
|
|
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
|
+
|
|
78
322
|
return $null
|
|
79
323
|
}
|
|
80
324
|
|
|
@@ -89,11 +333,16 @@ function Get-BashBlockReason {
|
|
|
89
333
|
)
|
|
90
334
|
|
|
91
335
|
$pattern = Get-BlockedPatternMatch -Command $Command
|
|
92
|
-
if (
|
|
93
|
-
return $
|
|
336
|
+
if ($pattern) {
|
|
337
|
+
return "Blocked dangerous command pattern detected: '$pattern'"
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
$readOp = Get-CdChainedReadCommandMatch -Command $Command
|
|
341
|
+
if ($readOp) {
|
|
342
|
+
return "Forbidden Bash pattern: 'cd ... && $readOp' (or ';'-chained). Claude Code's Bash permission engine cannot resolve a file-reading command ($readOp) against Read() rules once a preceding 'cd' has changed the working directory in the same command line - it always requires manual approval, regardless of any Read() or Bash() allow rule, and regardless of whether the path argument is relative or absolute. Rewrite as a single command using an absolute path instead of 'cd'-ing first, e.g. run $readOp directly against the absolute file path, with no leading 'cd'."
|
|
94
343
|
}
|
|
95
344
|
|
|
96
|
-
return
|
|
345
|
+
return $null
|
|
97
346
|
}
|
|
98
347
|
|
|
99
348
|
function Get-BashDenyDecision {
|
|
@@ -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
|
}
|