@danmoisan/drm-copilot-mcp 1.0.26 → 1.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/out/mcp-server.js +542 -41
  2. package/package.json +1 -1
  3. package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +109 -5
  4. package/resources/claude-customizations/.claude/hooks/enforce-mermaid-validation.ps1 +390 -0
  5. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +105 -2
  6. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConfig.psm1 +32 -52
  7. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusExtraction.psm1 +107 -99
  8. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusNormalization.psm1 +295 -0
  9. package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusValidation.psm1 +9 -3
  10. package/resources/claude-customizations/.claude/lib/mermaid/MermaidGrammar.psm1 +491 -0
  11. package/resources/claude-customizations/.claude/lib/mermaid/MermaidLineScanner.psm1 +488 -0
  12. package/resources/claude-customizations/.claude/lib/mermaid/MermaidMarkdownFences.psm1 +298 -0
  13. package/resources/claude-customizations/.claude/lib/mermaid/MermaidValidation.psm1 +496 -0
  14. package/resources/claude-customizations/.claude/rules/mermaid.md +142 -0
  15. package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +61 -1
  16. package/resources/claude-customizations/.claude/rules/plan-acceptance-gates.md +116 -0
  17. package/resources/claude-customizations/.claude/settings.json +5 -0
  18. package/resources/claude-customizations/.claude/skills/atomic-plan-contract/SKILL.md +15 -0
  19. package/resources/claude-customizations/.claude/skills/evidence-and-timestamp-conventions/SKILL.md +13 -0
  20. package/resources/claude-customizations/.claude/skills/feature-promotion-lifecycle/SKILL.md +6 -0
  21. package/resources/claude-customizations/.claude/skills/mermaid-diagram/SKILL.md +184 -0
  22. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/c4.md +50 -0
  23. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/class.md +63 -0
  24. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/er.md +56 -0
  25. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/flowchart.md +68 -0
  26. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/gantt.md +51 -0
  27. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/other-types.md +82 -0
  28. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/pie.md +32 -0
  29. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/sequence.md +63 -0
  30. package/resources/claude-customizations/.claude/skills/mermaid-diagram/references/state.md +49 -0
  31. package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +8 -7
  32. package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +24 -4
  33. package/resources/claude-customizations/config/blast-radius.json +8 -0
  34. package/resources/claude-customizations/pack-manifests/core.json +19 -1
  35. package/resources/codex-and-agents-customizations/.agents/skills/evidence-and-timestamp-conventions/SKILL.md +13 -0
  36. package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
  37. package/resources/customizations/.github/skills/evidence-and-timestamp-conventions/SKILL.md +13 -0
  38. package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +16 -1
@@ -54,6 +54,7 @@ Set-StrictMode -Version Latest
54
54
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusExtraction.psm1') -Force
55
55
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1') -Force
56
56
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusConfig.psm1') -Force
57
+ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusNormalization.psm1') -Force
57
58
  Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusValidation.psm1') -Force
58
59
 
59
60
  # Feature-folder handling. Every radius contains its own feature folder, and a
@@ -66,6 +67,11 @@ $script:FeatureFolderPrefix = 'docs/features/'
66
67
  $script:SourceDerived = 'derived'
67
68
  $script:SourceObserved = 'observed'
68
69
 
70
+ # A contract identifier names something callable or referenceable, so it must
71
+ # carry at least one ASCII letter (issue #489). Get-NormalizedDeclaredRadius
72
+ # re-applies that rule to identifiers a pre-#489 extractor already recorded.
73
+ $script:ContractLetterPattern = [regex]::new('[A-Za-z]')
74
+
69
75
  # Contention reason kinds, in the fixed order every result reports them. These
70
76
  # strings are contract literals consumed by the downstream parallel schema.
71
77
  $script:ConflictPathOverlap = 'path_overlap'
@@ -167,10 +173,20 @@ function Get-BlastRadius {
167
173
  $entry = [System.Collections.Generic.List[string]]::new()
168
174
  $entry.AddRange([string[]]@(Get-PlanPaths -PlanText $PlanText -RootSurface $rootSurface))
169
175
  $entry.AddRange([string[]]@(Get-PathFromLine -Line $specLine -RootSurface $rootSurface))
170
- $entry.Add((Get-FeatureFolderGlob -FeatureFolder (
176
+
177
+ # Read-by-mandate citations are dropped before the feature folder is added: a
178
+ # plan cites the policy rules because its author was told to read them, not
179
+ # because the change will write them (issue #489). Test-BlastRadius applies
180
+ # the same filter, which is what keeps the derived radius passing V1 and V2
181
+ # against its own plan. The feature-folder glob is added afterwards so it can
182
+ # never be excluded.
183
+ $surviving = [System.Collections.Generic.List[string]]::new()
184
+ $surviving.AddRange([string[]]@(Get-NonMandateReadEntry -Entry $entry.ToArray() `
185
+ -MandateRead ([string[]]@(Get-ConfigMandateRead -Config $Config))))
186
+ $surviving.Add((Get-FeatureFolderGlob -FeatureFolder (
171
187
  Get-RequiredText -Value $FeatureFolder -FieldName 'feature_folder')))
172
188
 
173
- $paths = [string[]]@(Get-OrdinalSortedEntry -Entry $entry.ToArray())
189
+ $paths = [string[]]@(Get-OrdinalSortedEntry -Entry $surviving.ToArray())
174
190
  $concrete = [string[]]@(Get-ConcreteEntry -Entry $paths)
175
191
 
176
192
  return ConvertTo-NormalizedBlastRadius -Radius @{
@@ -183,6 +199,92 @@ function Get-BlastRadius {
183
199
  }
184
200
  }
185
201
 
202
+ function Get-NormalizedDeclaredRadius {
203
+ <#
204
+ .SYNOPSIS
205
+ Re-apply the current extraction rules to an already-recorded radius.
206
+
207
+ .DESCRIPTION
208
+ Port of normalize_declared_radius. A radius recorded by an older
209
+ extractor can carry entries the current rules reject: directory-shaped
210
+ tokens, cross-corpus documentation globs, read-by-mandate citations, and
211
+ letterless contract tokens. Re-deriving from the plan text is not always
212
+ possible, so this function re-filters the recorded radius in place of a
213
+ fresh derivation and re-resolves the levels that depend on the surviving
214
+ paths (issue #489). source and computed_at are preserved and the input is
215
+ never mutated.
216
+
217
+ .PARAMETER Radius
218
+ A derived or declared radius hashtable to re-filter.
219
+
220
+ .PARAMETER Config
221
+ Parsed config/blast-radius.json. The root-surface set, the mandate-read
222
+ list, the module map, and the shared-surface list are all read from this
223
+ one value.
224
+
225
+ .OUTPUTS
226
+ System.Collections.Hashtable. A new radius carrying the surviving paths,
227
+ contracts re-filtered by the ASCII-letter rule, and modules and shared
228
+ surfaces re-resolved from the surviving paths. Throws when the radius
229
+ source is observed: an observed radius records a diff listing rather than
230
+ a plan-text harvest, so the plan-text acceptance rules do not apply to it
231
+ and re-filtering one would silently discard genuine evidence.
232
+ #>
233
+ [CmdletBinding()]
234
+ [OutputType([hashtable])]
235
+ param(
236
+ [Parameter(Mandatory = $true)]
237
+ [AllowNull()]
238
+ [object] $Radius,
239
+ [Parameter(Mandatory = $true)]
240
+ [AllowNull()]
241
+ [object] $Config
242
+ )
243
+
244
+ $normalized = ConvertTo-NormalizedBlastRadius -Radius $Radius
245
+
246
+ # Fail fast, before any other work, so the prohibition is unambiguous and no
247
+ # partially-filtered value can escape.
248
+ if ($normalized['source'] -eq $script:SourceObserved) {
249
+ throw ("Get-NormalizedDeclaredRadius rejects a radius whose source is " +
250
+ "'$($script:SourceObserved)': an observed radius records a diff listing, " +
251
+ 'not a plan-text harvest, so the plan-text acceptance rules must not ' +
252
+ 'be applied to it.')
253
+ }
254
+
255
+ $rootSurface = [string[]]@(Get-ConfigRootSurface -Config $Config)
256
+
257
+ # Re-run the classifier over each recorded entry. An entry the current rules
258
+ # reject is dropped; the mandate-read filter then removes the citations that
259
+ # are evidence of a read rather than of a write.
260
+ $accepted = [System.Collections.Generic.List[string]]::new()
261
+ foreach ($entry in @($normalized['paths'])) {
262
+ if ($null -ne (Get-PathTokenKind -Token $entry -RootSurface $rootSurface)) {
263
+ $accepted.Add([string]$entry)
264
+ }
265
+ }
266
+
267
+ $paths = [string[]]@(Get-NonMandateReadEntry -Entry $accepted.ToArray() `
268
+ -MandateRead ([string[]]@(Get-ConfigMandateRead -Config $Config)))
269
+ $concrete = [string[]]@(Get-ConcreteEntry -Entry $paths)
270
+
271
+ $contract = [System.Collections.Generic.List[string]]::new()
272
+ foreach ($identifier in @($normalized['contracts'])) {
273
+ if ($script:ContractLetterPattern.IsMatch([string]$identifier)) {
274
+ $contract.Add([string]$identifier)
275
+ }
276
+ }
277
+
278
+ return ConvertTo-NormalizedBlastRadius -Radius @{
279
+ paths = $paths
280
+ modules = @(Resolve-BlastRadiusModule -PathEntry $paths -Config $Config)
281
+ shared_surfaces = @(Resolve-BlastRadiusSharedSurface -ConcretePath $concrete -Config $Config)
282
+ contracts = @($contract.ToArray())
283
+ source = $normalized['source']
284
+ computed_at = $normalized['computed_at']
285
+ }
286
+ }
287
+
186
288
  function Get-BlastRadiusFromObservedPaths {
187
289
  <#
188
290
  .SYNOPSIS
@@ -374,6 +476,7 @@ function Test-BlastRadiusConflict {
374
476
  Export-ModuleMember -Function `
375
477
  Get-PlanPaths, `
376
478
  Get-BlastRadius, `
479
+ Get-NormalizedDeclaredRadius, `
377
480
  Get-BlastRadiusFromObservedPaths, `
378
481
  Test-BlastRadius, `
379
482
  Test-BlastRadiusConflict
@@ -39,6 +39,7 @@ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'BlastRadiusGlob.psm1')
39
39
  $script:ConfigSharedSurfaceKey = 'shared_surfaces'
40
40
  $script:ConfigSharedSurfaceGlobKey = 'shared_surface_globs'
41
41
  $script:ConfigModuleKey = 'modules'
42
+ $script:ConfigMandateReadKey = 'mandate_reads'
42
43
  $script:ConfigOverBreadthKey = 'over_breadth_fraction'
43
44
 
44
45
  # Numeric types a JSON or literal truth table may carry for the V3 threshold.
@@ -279,6 +280,36 @@ function Get-ConfigRootSurface {
279
280
  return @($rootSurface.ToArray())
280
281
  }
281
282
 
283
+ function Get-ConfigMandateRead {
284
+ <#
285
+ .SYNOPSIS
286
+ Read the read-by-mandate exclusion list from the truth table.
287
+
288
+ .DESCRIPTION
289
+ Port of config_mandate_reads. Mandate reads are the paths every agent is
290
+ instructed to read before doing any work, so a citation of one of them is
291
+ evidence that the author obeyed the reading order rather than evidence
292
+ that the change will write the file (issue #489).
293
+
294
+ .PARAMETER Config
295
+ Parsed config/blast-radius.json. Only the mandate_reads key is read.
296
+
297
+ .OUTPUTS
298
+ System.Object[]. Entries sorted and deduplicated by the underlying
299
+ reader. A config with no mandate_reads key yields an empty array, which
300
+ excludes nothing and reproduces pre-change behavior.
301
+ #>
302
+ [CmdletBinding()]
303
+ [OutputType([System.Object[]])]
304
+ param(
305
+ [Parameter(Mandatory = $true)]
306
+ [AllowNull()]
307
+ [object] $Config
308
+ )
309
+
310
+ return @(Get-ConfigStringList -Config $Config -Key $script:ConfigMandateReadKey)
311
+ }
312
+
282
313
  function Get-ConfigModuleEntry {
283
314
  <#
284
315
  .SYNOPSIS
@@ -374,57 +405,6 @@ function Get-ConfigOverBreadthFraction {
374
405
  return $fraction
375
406
  }
376
407
 
377
- function Resolve-BlastRadiusModule {
378
- <#
379
- .SYNOPSIS
380
- Resolve path entries to the module names of the truth-table map.
381
-
382
- .DESCRIPTION
383
- Port of resolve_modules. A module joins the radius as soon as one of its
384
- globs covers one entry, so the search stops at the first hit per module.
385
- A path matching no glob resolves to no module.
386
-
387
- .PARAMETER PathEntry
388
- Concrete paths and globs of a radius. An empty collection is accepted.
389
-
390
- .PARAMETER Config
391
- Parsed config/blast-radius.json.
392
-
393
- .OUTPUTS
394
- System.Object[]. Matched module names, deduplicated and ordinally sorted.
395
- #>
396
- [CmdletBinding()]
397
- [OutputType([System.Object[]])]
398
- param(
399
- [Parameter(Mandatory = $true)]
400
- [AllowEmptyCollection()]
401
- [AllowEmptyString()]
402
- [string[]] $PathEntry,
403
- [Parameter(Mandatory = $true)]
404
- [AllowNull()]
405
- [object] $Config
406
- )
407
-
408
- $matched = [System.Collections.Generic.List[string]]::new()
409
- foreach ($pair in @(Get-ConfigModuleEntry -Config $Config)) {
410
- foreach ($pattern in $pair['globs']) {
411
- $hit = $false
412
- foreach ($entry in $PathEntry) {
413
- if (Test-GlobMatch -Pattern $pattern -Candidate $entry) {
414
- $matched.Add([string]$pair['name'])
415
- $hit = $true
416
- break
417
- }
418
- }
419
- if ($hit) {
420
- break
421
- }
422
- }
423
- }
424
-
425
- return @(Get-OrdinalSortedEntry -Entry $matched.ToArray())
426
- }
427
-
428
408
  function Resolve-BlastRadiusSharedSurface {
429
409
  <#
430
410
  .SYNOPSIS
@@ -485,7 +465,7 @@ Export-ModuleMember -Function `
485
465
  Get-RequiredMapping, `
486
466
  Get-ConfigStringList, `
487
467
  Get-ConfigRootSurface, `
468
+ Get-ConfigMandateRead, `
488
469
  Get-ConfigModuleEntry, `
489
470
  Get-ConfigOverBreadthFraction, `
490
- Resolve-BlastRadiusModule, `
491
471
  Resolve-BlastRadiusSharedSurface
@@ -6,8 +6,11 @@
6
6
  Destination-runtime PowerShell port of the text-scanning half of
7
7
  scripts/dev_tools/_blast_radius_extraction.py. Normalizes line endings,
8
8
  partitions atomic-plan lines, extracts backtick-delimited inline-code tokens,
9
- classifies those tokens as concrete repository paths or globs, and extracts
10
- contract identifiers from a feature spec's interface sections.
9
+ and classifies those tokens as concrete repository paths or globs.
10
+ Get-ContractIdentifier was relocated to BlastRadiusNormalization.psm1 so this
11
+ module stays inside the 500-line limit (issue #489); the three module-scoped
12
+ heading variables it read travelled with it, because $script: scope is per
13
+ module.
11
14
 
12
15
  The Python module remains the authoritative reference implementation. This
13
16
  module is one half of a two-language mirror; it never imports validator
@@ -53,17 +56,27 @@ $script:PlanTaskPattern = [regex]::new(
53
56
  # on its own line, from producing spurious spans.
54
57
  $script:InlineCodeSpanPattern = [regex]::new('`([^`]+)`')
55
58
 
56
- # Markdown ATX heading pattern used to locate spec interface sections.
57
- $script:HeadingPattern = [regex]::new('^(?<hashes>#{1,6}) (?<title>.+)$')
58
-
59
59
  # Top-level directories of this repository. A token starting with one of these is
60
- # accepted without needing a recognized extension, which admits directory-shaped
61
- # tokens and ** globs.
60
+ # accepted without needing a recognized extension, which admits ** globs naming a
61
+ # subtree. artifacts/ is deliberately absent (issue #489): the process-artifact
62
+ # tree is read by mandate rather than written by a work item, so admitting
63
+ # artifacts/** as a subtree claim made unrelated items contend.
62
64
  $script:KnownTopLevelSegment = @(
63
65
  'scripts/', 'tests/', 'docs/', 'config/', 'schemas/', 'packages/',
64
- 'extensions/', '.claude/', '.codex/', '.github/', '.agents/', 'artifacts/'
66
+ 'extensions/', '.claude/', '.codex/', '.github/', '.agents/'
65
67
  )
66
68
 
69
+ # A file citation may carry a trailing line reference such as :90. The suffix is
70
+ # stripped before the extension test so a line-anchored citation keeps the
71
+ # acceptance its unanchored form has; the token itself is recorded verbatim.
72
+ $script:LineSuffixPattern = [regex]::new(':\d+$')
73
+
74
+ # Documentation-corpus root and the index, counted after that prefix, of the
75
+ # segment that names one feature folder. A glob whose wildcard reaches this
76
+ # segment or any earlier one claims every feature folder in the corpus.
77
+ $script:FeatureCorpusPrefix = 'docs/features/'
78
+ $script:FeatureFolderSegmentIndex = 1
79
+
67
80
  # Fallback acceptance rule: a token shaped <segment>/.../<name>.<ext> counts as a
68
81
  # repository path when its final component carries one of these extensions.
69
82
  $script:RecognizedPathExtension = [System.Collections.Generic.HashSet[string]]::new(
@@ -74,19 +87,11 @@ $script:RecognizedPathExtension = [System.Collections.Generic.HashSet[string]]::
74
87
  ),
75
88
  [StringComparer]::Ordinal)
76
89
 
77
- # A spec section qualifies as an interface section when its heading, or the
78
- # heading of an ancestor section, contains one of these words.
79
- $script:ContractHeadingKeyword = @('API', 'Interface', 'Contract', 'Surface')
80
-
81
90
  # Classification vocabulary for accepted path tokens. Concrete entries take part
82
91
  # in exact-match checks; glob entries cannot and are matched by pattern.
83
92
  $script:PathKindConcrete = 'concrete'
84
93
  $script:PathKindGlob = 'glob'
85
94
 
86
- # Heading depth sentinel standing in for the Python `qualifying_depth is None`
87
- # state: markdown heading levels are 1..6, so 0 can never be a real level.
88
- $script:NoQualifyingHeadingDepth = 0
89
-
90
95
 
91
96
  function ConvertTo-NormalizedLine {
92
97
  <#
@@ -228,6 +233,60 @@ function Get-InlineCodeToken {
228
233
  return @($token.ToArray())
229
234
  }
230
235
 
236
+ function Test-MultipleFeatureFolderSpan {
237
+ <#
238
+ .SYNOPSIS
239
+ Report whether a glob claims more than one documentation feature folder.
240
+
241
+ .DESCRIPTION
242
+ Port of spans_multiple_feature_folders. The documentation corpus is laid
243
+ out as docs/features/<bucket>/<feature-folder>/..., so a glob whose
244
+ wildcard occupies or truncates the feature-folder segment claims every
245
+ feature folder in the corpus. That made two unrelated work items contend
246
+ purely because both wrote documentation (issue #489). A glob carrying a
247
+ complete, wildcard-free feature-folder segment claims one folder and is
248
+ retained.
249
+
250
+ .PARAMETER Token
251
+ A wildcard-bearing token already accepted by the shape rules of
252
+ Get-PathTokenKind.
253
+
254
+ .OUTPUTS
255
+ System.Boolean. True when the token is rooted in the documentation corpus
256
+ and its wildcard reaches the feature-folder segment or any earlier one.
257
+ #>
258
+ [CmdletBinding()]
259
+ [OutputType([bool])]
260
+ param(
261
+ [Parameter(Mandatory = $true)]
262
+ [AllowEmptyString()]
263
+ [string] $Token
264
+ )
265
+
266
+ if (-not $Token.StartsWith($script:FeatureCorpusPrefix,
267
+ [System.StringComparison]::Ordinal)) {
268
+ return $false
269
+ }
270
+
271
+ $segment = @($Token.Substring($script:FeatureCorpusPrefix.Length) -split '/')
272
+
273
+ # A token that stops at or before the feature-folder segment has had that
274
+ # segment truncated away by the wildcard, so it spans the whole corpus.
275
+ if ($segment.Count -le $script:FeatureFolderSegmentIndex) {
276
+ return $true
277
+ }
278
+
279
+ # Every segment up to and including the feature-folder name must be a literal
280
+ # for the claim to resolve to exactly one folder.
281
+ for ($index = 0; $index -le $script:FeatureFolderSegmentIndex; $index++) {
282
+ if ($segment[$index].IndexOf('*') -ge 0) {
283
+ return $true
284
+ }
285
+ }
286
+
287
+ return $false
288
+ }
289
+
231
290
  function Get-PathTokenKind {
232
291
  <#
233
292
  .SYNOPSIS
@@ -287,14 +346,30 @@ function Get-PathTokenKind {
287
346
  }
288
347
 
289
348
  # Read the final component's extension for the fallback acceptance rule; a
290
- # component with no dot (a directory name or **) has no extension.
291
- $finalComponent = $Token.Substring($Token.LastIndexOf('/') + 1)
349
+ # component with no dot (a directory name or **) has no extension. A trailing
350
+ # line reference is stripped first so file.md:90 reads as md rather than as
351
+ # the unrecognized extension md:90.
352
+ $finalComponent = $script:LineSuffixPattern.Replace(
353
+ $Token.Substring($Token.LastIndexOf('/') + 1), '')
292
354
  $extension = ''
293
355
  $dotIndex = $finalComponent.LastIndexOf('.')
294
356
  if ($dotIndex -ge 0) {
295
357
  $extension = $finalComponent.Substring($dotIndex + 1).ToLowerInvariant()
296
358
  }
297
359
 
360
+ $hasExtension = $script:RecognizedPathExtension.Contains($extension)
361
+
362
+ # A wildcard-free token must name a file, not a directory (issue #489). A
363
+ # directory-shaped token such as scripts/dev_tools is a location reference,
364
+ # not a write claim, and admitting it made every item touching anything under
365
+ # that directory contend at the path level.
366
+ if ($Token.IndexOf('*') -lt 0) {
367
+ if ($hasExtension) {
368
+ return $script:PathKindConcrete
369
+ }
370
+ return $null
371
+ }
372
+
298
373
  $hasKnownSegment = $false
299
374
  foreach ($segment in $script:KnownTopLevelSegment) {
300
375
  if ($Token.StartsWith($segment, [System.StringComparison]::Ordinal)) {
@@ -303,18 +378,23 @@ function Get-PathTokenKind {
303
378
  }
304
379
  }
305
380
 
306
- # Failing both shape rules means the token is prose or a non-path expression
307
- # that merely contains a separator, so it is dropped.
308
- if (-not $hasKnownSegment -and -not $script:RecognizedPathExtension.Contains($extension)) {
381
+ # A wildcard-bearing token must still satisfy one of the two documented shape
382
+ # rules; failing both means the token is prose or a non-path expression that
383
+ # merely contains a separator, so it is dropped.
384
+ if (-not $hasKnownSegment -and -not $hasExtension) {
385
+ return $null
386
+ }
387
+
388
+ # A documentation glob spanning the whole feature corpus is a cross-corpus
389
+ # claim rather than a write claim, so it is dropped before it can become a
390
+ # radius entry.
391
+ if (Test-MultipleFeatureFolderSpan -Token $Token) {
309
392
  return $null
310
393
  }
311
394
 
312
395
  # An accepted token carrying a wildcard names a set of files, so it cannot
313
396
  # take part in concrete exact-match comparisons and is recorded as a glob.
314
- if ($Token.IndexOf('*') -ge 0) {
315
- return $script:PathKindGlob
316
- }
317
- return $script:PathKindConcrete
397
+ return $script:PathKindGlob
318
398
  }
319
399
 
320
400
  function Get-PathFromLine {
@@ -407,84 +487,12 @@ function Get-PlanPaths {
407
487
  return @(Get-PathFromLine -Line $allLine.ToArray() -RootSurface $RootSurface)
408
488
  }
409
489
 
410
- function Get-ContractIdentifier {
411
- <#
412
- .SYNOPSIS
413
- Extract contract identifiers from a spec's interface sections.
414
-
415
- .DESCRIPTION
416
- Port of extract_contract_identifiers. Implements the contracts level of
417
- the radius model: exported symbols, schema names, and CLI identifiers
418
- named in inline code inside sections whose heading, or an ancestor
419
- heading, contains API, Interface, Contract, or Surface. Markdown sections
420
- nest, so a heading deeper than the innermost qualifying heading stays
421
- inside that section and inherits its qualification; a heading at or above
422
- that level ends the section and is judged on its own title.
423
-
424
- .PARAMETER SpecText
425
- Full feature spec.md document text; may be empty.
426
-
427
- .OUTPUTS
428
- System.Object[]. Identifiers, deduplicated and ordinally sorted. Tokens
429
- containing a separator are excluded as path references.
430
- #>
431
- [CmdletBinding()]
432
- [OutputType([System.Object[]])]
433
- param(
434
- [Parameter(Mandatory = $true)]
435
- [AllowEmptyString()]
436
- [string] $SpecText
437
- )
438
-
439
- $identifier = [System.Collections.Generic.List[string]]::new()
440
- $qualifyingDepth = $script:NoQualifyingHeadingDepth
441
-
442
- foreach ($line in @(ConvertTo-NormalizedLine -Text $SpecText)) {
443
- $headingMatch = $script:HeadingPattern.Match($line)
444
-
445
- # A heading changes the section context and contributes no identifiers of
446
- # its own, so each heading is handled and the line is then skipped.
447
- if ($headingMatch.Success) {
448
- $headingLevel = $headingMatch.Groups['hashes'].Value.Length
449
- if ($qualifyingDepth -ne $script:NoQualifyingHeadingDepth -and
450
- $headingLevel -gt $qualifyingDepth) {
451
- continue
452
- }
453
-
454
- $headingTitle = $headingMatch.Groups['title'].Value
455
- $qualifyingDepth = $script:NoQualifyingHeadingDepth
456
- foreach ($keyword in $script:ContractHeadingKeyword) {
457
- if ($headingTitle.IndexOf($keyword, [System.StringComparison]::Ordinal) -ge 0) {
458
- $qualifyingDepth = $headingLevel
459
- break
460
- }
461
- }
462
- continue
463
- }
464
-
465
- if ($qualifyingDepth -eq $script:NoQualifyingHeadingDepth) {
466
- continue
467
- }
468
-
469
- # Inside a qualifying section an inline-code token without a separator is
470
- # a contract identifier; a token with one is a path reference and is
471
- # recorded at the paths level instead.
472
- foreach ($token in @(Get-InlineCodeToken -Line $line)) {
473
- if ($token.IndexOf('/') -lt 0) {
474
- $identifier.Add($token)
475
- }
476
- }
477
- }
478
-
479
- return @(Get-OrdinalSortedEntry -Entry $identifier.ToArray())
480
- }
481
-
482
490
  Export-ModuleMember -Function `
483
491
  Get-OrdinalSortedEntry, `
484
492
  ConvertTo-NormalizedLine, `
485
493
  Get-PlanLineScan, `
486
494
  Get-InlineCodeToken, `
495
+ Test-MultipleFeatureFolderSpan, `
487
496
  Get-PathTokenKind, `
488
497
  Get-PathFromLine, `
489
- Get-PlanPaths, `
490
- Get-ContractIdentifier
498
+ Get-PlanPaths