Skip to content

Match slice patterns with regex like ArchUnit - #516

Open
alexanderlinne wants to merge 1 commit into
identify-slices-by-capturesfrom
match-slice-patterns-with-regex
Open

alexanderlinne wants to merge 1 commit into
identify-slices-by-capturesfrom
match-slice-patterns-with-regex

Conversation

@alexanderlinne

Copy link
Copy Markdown
Collaborator

Fixes #468
Fixes #208

Why

Slice patterns were matched by string surgery: everything after the first (* was replaced with (**)., so every pattern behaved as if it ended in a greedy capture.

  • (*) and (**) selected the same slices.
  • (*), (*).., (*).(*) and (*)..(*) all produced the same nine slices.
  • A trailing literal such as (**).Service.. matched nothing.
  • Alternation and wildcards were ignored outright.

The exporter compensated with RemovePatternInappropriateSlices, which dropped every slice with at least as many dots as the pattern had (*) groups, so a diagram showed fewer slices than the rule checked.

What changes

Matching. The pattern is converted to a regular expression, ported from ArchUnit's PackageMatcher:

Token Meaning
(*) captures exactly one segment
(**) captures one or more segments
(A|B) captures one of the alternatives
* matches one segment without capturing it
.. matches zero or more segments
[A|B] matches one of the alternatives

.. deviates from ArchUnit on purpose. ArchUnit's expression for it can match the empty string in the middle of a segment (App.(*)..(*) on App.Single would capture Singl and e), so it is expanded depending on where it sits and always consumes the separating dot. The method documents why.

Malformed patterns are rejected by Matching/MatchingWithPackages when the rule is defined, instead of on the first type that happens to be checked.

This is what fixes both issues:

Several capture groups. Every capture group now captures a part of the slice, so a pattern with several groups gives slices with several parts. #515 already defined how those are identified, named and frozen:

Pattern Namespace Slice
App.(*).(*) App.Orders.Http Slice Orders - Http
App.(*).Service.(*) App.Orders.Service.Http Slice Orders - Http
App.(*)..(*) App.Orders.Web.Http Slice Orders - Http
App.(*).*.(*) App.Orders.Web.Http Slice Orders - Http

The pattern text between the groups only selects types, so all four are the same slice.

Nested diagrams need nothing from the pattern: slices with several parts nest at their types' common namespace like any other (#513). Focus mode and LimitDependencies, which use that namespace too, need no change either. They apply to slices with several parts as they are.

(*).(*) and (*)..(*) no longer draw the same diagram:

  • (*).(*) needs exactly two segments, so the top-level SliceN classes belong to no slice any more, and the package arrows that came from them are gone.
  • (*)..(*) also picks up the Inner namespaces. The slice Slice3 - Inner holds the types of Group1.Inner and Group2.Inner, so it sits in Slice3.
pr5-2-multiple-capture-groups

Only the types decide where a slice sits: Slice Slice3 - Inner sits at Slice3.Group1.Inner for (*).Group1.(*), and at Slice3 for (*).*.(*), whose Inner slice spans both groups. Before, both patterns drew the same diagram as (*).(*) above:

pr5-3-literal-and-wildcard-between-groups

Where names no longer say where a slice lives, focus mode and LimitDependencies still work, as they go by namespaces. With …MultipleSubnamespaces..(*), the slices Group1, Group2, Inner and Slice3 all lie in Slice3, so focusing on …Slice3 focuses all four. LimitDependencies drops Group1 → Slice3 and Service → Slice3, because those slices lie at depths 4, 2 and 3, although none of the names has a dot. New snapshots pin both.

Removed. A (*) capture can no longer contain a dot, so RemovePatternInappropriateSlices can't remove anything any more. It is removed, along with the CountOfAsteriskInPattern it consumed.

Docs. The XML documentation of SliceRuleInitializer and SliceIdentifier describes the pattern syntax and the multi-capture naming. The guide's "Slice Rules" section gains:

  • the pattern token table and the validation rules,
  • the multi-capture naming rules,
  • where a nested slice is drawn.

Section 5.4 describes repeated (*) groups.

Breaking changes

  • (*) matches exactly one namespace segment, so Matching("App.(*)") leaves out every type below App.<X>.<Y> instead of giving it a slice of its own. Use App.(*).. to fold those types into their top-level slice, or App.(**) to keep one slice per namespace.
  • (**).. now also includes the types directly in the captured namespace.
  • Rules can therefore report different violations, and slice names in failure messages and frozen-rule stores change along with the slices. Rules with several capture groups have to be re-frozen once.
  • Slice.CountOfAsteriskInPattern, SliceIdentifier.CountOfAsteriskInPattern and the countOfAsteriskInPattern parameter of SliceIdentifier.Of are removed.
How the images were made

Both images render the .verified.txt snapshots at #515 and at this PR, on the MultipleSubnamespaces fixture; the "before" side of image 2 is the same diagram as that of image 1. They were rendered with PlantUML 1.2025.4 and Graphviz, with the remote !include of C4-PlantUML replaced by PlantUML's bundled copy (!include <C4/C4_Container>).

@alexanderlinne
alexanderlinne added this pull request to stack #514 October 29, 2026 21:59
@codecov-commenter

codecov-commenter commented Oct 29, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.48%. Comparing base (54bb0ea) to head (f702e73).

Additional details and impacted files
@@                     Coverage Diff                      @@
##           identify-slices-by-captures     #516   +/-   ##
============================================================
  Coverage                        86.48%   86.48%           
============================================================
  Files                              261      261           
  Lines                            12676    12679    +3     
  Branches                          1232     1231    -1     
============================================================
+ Hits                             10963    10966    +3     
  Misses                            1366     1366           
  Partials                           347      347           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@alexanderlinne
alexanderlinne force-pushed the match-slice-patterns-with-regex branch from 4c67d23 to 756f9a3 Compare October 30, 2026 19:44
@alexanderlinne
alexanderlinne marked this pull request as ready for review October 30, 2026 19:51
Slice patterns were matched by string surgery: the pattern was split at
the first "(*", and everything after it was discarded and replaced with
"(**).". Every pattern therefore behaved as if it ended in a greedy
capture, so "(*)", "(*)..", "(*).(*)" and "(*)..(*)" all produced the
same nine slices, a trailing literal such as "(**).Service.." matched
nothing, and alternation and wildcards were ignored outright.

Convert the pattern to a regular expression instead, porting
PackageMatcher.convertToRegex from ArchUnit. "(*)" now captures exactly
one namespace segment, "(**)" one or more, "*" matches a segment without
capturing it, ".." skips segments, and "[A|B]" alternates. "(A|B)" is a
capture group, as it is in ArchUnit: the regex captures it like any
other group, so the "has to contain a capture group" check accepts it as
well.

".." deviates from upstream. ArchUnit's TWO_DOTS_REGEX,

    (?:(?:^\w*)?\.(?:\w+\.)*(?:\w*$)?)?

is optional as a whole and brings its own dots, so it also matches the
empty string in the middle of a segment ("App.(*)..(*)" on App.Single
would capture ["Singl", "e"]). Expand ".." depending on where it sits in
the pattern instead, so the separating dot is always consumed:

  leading    (?:\w+\.)*      skip whole leading segments
  trailing   (?:\.\w+)*      skip whole trailing segments
  inner      \.(?:\w+\.)*    the separating dot, then whole segments

The expansion stays greedy; only the mid-segment matches are gone. The
deviation is recorded on the method.

Every capture group now captures a part of the slice, so a pattern with
several groups gives slices with several parts, named like ArchUnit:

  App.(*).(*)          App.Orders.Http          Slice Orders - Http
  App.(*).Service.(*)  App.Orders.Service.Http  Slice Orders - Http
  App.(*)..(*)         App.Orders.Web.Http      Slice Orders - Http

Nested diagrams need nothing from the pattern; slices with several
parts nest at their types' common namespace like any other, and focus
mode and LimitDependencies, which use that namespace too, need no
change.

Pattern validation moves from the per-type assignment callback to
Matching/MatchingWithPackages, so a malformed pattern is rejected where
it is written rather than on the first type that happens to be checked.
The "has to contain a capture group" check is deliberately last: a
pattern that is malformed *and* has no capture group should report the
specific problem.

The old matcher could produce slice names containing dots even for a
pattern made only of "(*)" groups, and PlantUmlFileBuilder compensated
with RemovePatternInappropriateSlices, which dropped every slice with at
least as many dots as the pattern had "(*)" groups. Now that "(*)"
expands to \w+, a captured part can never contain a dot, so that filter
can no longer remove anything. Remove it along with the
CountOfAsteriskInPattern it consumed.

Document the pattern syntax and the multi-capture naming on
SliceRuleInitializer and SliceIdentifier, and add the pattern token
table and the multi-capture naming rules to the guide's "Slice Rules"
section.

Visible in the tests:

- "SubnamespaceCircle.(*).." now folds Slice2.Inner into Slice2 and so
  reports the cycle Slice1 -> Slice2 -> Slice1. It used to
  give Inner a slice of its own and see no cycle.
- "MultipleSubnamespaces.(**).." no longer drops the top-level classes,
  so it now sees the Slice3 -> Slice1 edge that closes the loop.
- The DotDotSemantics fixture shows ".." no longer splitting
  "AlphaService" or merging it with "Alpha.Service".

BREAKING CHANGE: slice membership follows the pattern now. "(*)" matches
exactly one namespace segment, so Matching("App.(*)") leaves out every
type below App.<X>.<Y> instead of giving it a slice of its own -- use
"App.(*).." to fold those types into their top-level slice, or "App.(**)"
to keep one slice per namespace. "(**).." now also includes the types
directly in the captured namespace. Rules can therefore report different
violations, and slice names in failure messages and frozen-rule stores
change along with the slices. Rules with several capture groups have to
be re-frozen once.

BREAKING CHANGE: Slice.CountOfAsteriskInPattern,
SliceIdentifier.CountOfAsteriskInPattern and the countOfAsteriskInPattern
parameter of SliceIdentifier.Of are gone.

Signed-off-by: Alexander Linne <alexander.linne@tngtech.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants