Skip to content

feat: add LC0098 - Event subscriber name does not match the configured template#408

Merged
Arthurvdv merged 4 commits into
ALCops:mainfrom
MODUSCarstenScholling:dev-cs-new-subscriber-naming-template
Jul 20, 2026
Merged

feat: add LC0098 - Event subscriber name does not match the configured template#408
Arthurvdv merged 4 commits into
ALCops:mainfrom
MODUSCarstenScholling:dev-cs-new-subscriber-naming-template

Conversation

@MODUSCarstenScholling

Copy link
Copy Markdown
Contributor

feat: add LC0098 - Event subscriber name does not match the configured template

Adds LinterCop rule LC0098 EventSubscriberNamingPattern with CodeFix (BatchFixer FixAll).
The rule reports when an event subscriber procedure name does not match the identifier derived from its [EventSubscriber] attribute using one configurable template.

Default template:

{Event Source}_{Event Name}[_{Element Name}]

This default matches the AL Language extension Find Event output verbatim (raw source and element names, quoted when required). Teams already using the tooling default get zero-config alignment. Severity is Info, category Naming.

Template syntax

  • Token placeholders support source object name, event name, and element name across five styles: raw, PascalCase, camelCase, snake_case, kebab-case.
  • Raw placeholders emit values verbatim with no splitting or casing transform.
  • Non-raw placeholders use shared word splitting and rendering in ALCops.Common.
  • Optional groups in brackets are emitted only when all inner token placeholders are non-empty.
  • Unknown brace sequences are emitted verbatim; new placeholders can be added via the parser placeholder map without grammar changes.

Acronym-aware rendering

For non-raw styles, rendering now produces an accepted-name set:

  • Preferred name is always first and follows original casing wins.
  • Additional accepted variants are optionally added when KnownAcronyms contains a differently cased entry for an uppercase-carrying source word with the same case-insensitive key.
  • A diagnostic is reported only when the method name matches none of the accepted variants (ordinal comparison).
  • The CodeFix always suggests the preferred name.

Per-word rules:

  • ID (any casing) becomes Id.
  • Two-letter all-uppercase words stay uppercase.
  • Words containing uppercase preserve original casing shape (first letter normalized to upper as needed).
  • All-lowercase registered words use registry canonical casing.
  • All-lowercase unregistered words use first-upper fallback.

Configuration

alcops.json properties:

  • SubscriberNamingPattern: string, default null (built-in default template used)
  • KnownAcronyms: merged with built-in defaults, user entries override on same case-insensitive key

KnownAcronyms semantics:

  • For all-lowercase source words: defines preferred canonical casing.
  • For uppercase-carrying source words: can add accepted variants alongside the original spelling.
  • Preferred name remains original-casing form for uppercase-carrying sources; CodeFix suggestion remains that preferred form.

Example:

If source contains LCY and KnownAcronyms includes Lcy:

  • ...LCY is accepted and remains preferred
  • ...Lcy is additionally accepted
  • ...LcY is still rejected

Safety guards

LC0098 suppresses diagnostics when rename would be unsafe or invalid:

  • AL304 length guard: preferred name longer than 120 chars is skipped.
  • Duplicate-name collision guard: skipped when a sibling already has preferred name or another subscriber would compute to the same preferred name.
  • Unresolvable source objects and obsolete methods are skipped.

Shared infrastructure in ALCops.Common

  • AcronymRegistry: built-in list plus user merge/override behavior
  • IdentifierCaseStyle: shared style enum
  • IdentifierNameRenderer: splitting and casing renderer, including accepted-variant generation

Interaction with LC0092

LC0098 and LC0092 are orthogonal: structural template validation vs regex naming validation.
Both can report independently.

CodeFix

Renames only subscriber declaration identifier via syntax token replacement.
PreferredName is passed from analyzer to CodeFix through diagnostic properties, so CodeFix does not recompute naming input.
Supports FixAll via BatchFixer.

Compatibility

Full netstandard2.1 support; no net8-only SDK dependency required.

Tests (updated)

  • ALCops.Common.Test: renderer coverage extended to accepted-variant behavior (including cross-product and duplicate prevention).
  • LC0098 tests extended with new fixtures for:
    • acceptance of original uppercase acronym spelling
    • acceptance of user-pinned variant alongside original spelling
    • rejection of third mixed variant

Implements #406

…d template

Adds LinterCop rule LC0098 EventSubscriberNamingPattern with CodeFix
(BatchFixer FixAll). Reports when an event subscriber procedure name does
not match the identifier derived from its [EventSubscriber] attribute via
a single configurable template.

Default template: {Event Source}_{EventName}[_{Element Name}]

This produces exactly the identifier the AL Language extension's "Find
Event" feature generates verbatim (raw source and element names, quoted
when they contain characters that require AL identifier quoting). Teams
that already accept the tooling default satisfy the rule with zero
configuration. Severity Info, category Naming.

Template syntax
---------------
* Token placeholders address three tokens (source object name, event
  name, element name) and five output styles:
    Raw            {Event Source} / {Event Name} / {Element Name}
    PascalCase     {EventSource}  / {EventName}  / {ElementName}
    camelCase      {eventSource}  / {eventName}  / {elementName}
    snake_case     {event_source} / {event_name} / {element_name}
    kebab-case     {event-source} / {event-name} / {element-name}
  Raw placeholders emit the token verbatim (no word splitting, no case
  transform). Non-raw placeholders route through the shared word
  splitter and case-style renderer in ALCops.Common.
* Optional groups [...] are emitted only when every token placeholder
  inside resolves to a non-empty value. Handles the common case where
  the element name is absent without leaving orphan separators.
* Unknown {...} sequences are passed through verbatim; adding new tokens
  (e.g. {ObjectType}, {ObjectId}) is a forward-compatible dictionary
  entry in TemplateParser.KnownPlaceholders — no grammar change.

Acronym-aware canonical rendering
---------------------------------
For non-raw styles, each word is rendered to exactly one canonical
form. The renderer follows an "original casing wins" rule: as long as
the source word carries any uppercase character, its casing is
preserved and the acronym registry is not consulted. Only all-lowercase
source words fall back to the registry to recover a canonical casing.

* ID (any casing)                              -> Id
* Two-letter all-uppercase word                -> kept uppercase
                                                  (IOLog, not IoLog)
* Word contains any uppercase character        -> EnsureUpperFirst
  (VAT, Sales, Http, XYZ)                        (registry NOT consulted;
                                                  original casing wins)
* All-lowercase word, registered acronym       -> canonical casing from
  (vat, odata, acme)                             registry (VAT, OData, Acme)
* All-lowercase word, not registered           -> first-upper, remainder
  (amount, header)                               as-is (Amount, Header)

The comparison is byte-for-byte ordinal, so exactly one spelling per
subscriber is accepted; the CodeFix always suggests that canonical name.

Configuration
-------------
alcops.json:
  "SubscriberNameTemplate": "<template>"   default: null (built-in)
  "KnownAcronyms": ["Acme", "MyCo", ...]   merged into built-in list

User acronym entries are merged with the built-in defaults and take
precedence on identical case-insensitive keys. Entries only affect
all-lowercase source words: KnownAcronyms cannot re-cast Microsoft-
or partner-owned identifiers that already carry an uppercase signal
(field "VAT Amount" stays VATAmount regardless of a "Vat" entry;
field "vat amount" becomes VATAmount when VAT is registered and
VatAmount when it is not).

Safety guards
-------------
LC0098 never emits a diagnostic that would move the violation from
itself to another rule or to a compile error:

* AL304 length guard: the canonical name is capped at 120 characters
  (MaxAlIdentifierLength). Longer suggestions are suppressed. A W1
  codebase survey found only two derived names above the limit.

* Duplicate-name collision guard: suppressed when a sibling in the
  same containing type already carries the canonical name, or when
  another event subscriber in that type would compute to the same
  canonical name. Self-identity is checked via ISymbol.Equals (AL
  allows method overloading; name-based self-filtering is unsafe).
  The guard is deliberately conservative — any sibling name clash
  suppresses, even when signatures technically differ.

* Unresolvable source objects and obsolete methods are skipped.

Shared infrastructure in ALCops.Common
--------------------------------------
* Helpers/AcronymRegistry.cs      — built-in defaults + user merge
* Helpers/IdentifierCaseStyle.cs  — case-style enum
* Helpers/IdentifierNameRenderer.cs — word splitter + per-word renderer
Placed in Common so future identifier-generating rules can consume the
same registry and renderer. Full unit-test coverage for both new helpers
under ALCops.Common.Test/Helpers/.

Interaction with LC0092
-----------------------
LC0092 (NamingPattern) also targets EventSubscriber methods via its
regex-based EventSubscriber sub-target. The two rules are orthogonal:
structural template (LC0098) vs. character-class pattern (LC0092), and
their settings are decoupled. When the default LC0098 template produces
a quoted identifier because the source object name begins with a
non-letter, LC0092's default ^[A-Z] sees the opening quote — teams that
hit this should adjust their EventSubscriber pattern accordingly. Both
instruction files carry the cross-reference.

CodeFix
-------
Renames only the subscriber's declaration identifier via
SyntaxNode.ReplaceToken; call sites are not touched because subscribers
are wired by the [EventSubscriber] attribute, not by name. The preferred
name is passed via diagnostic.Properties from analyzer to CodeFix so the
CodeFix does not need to reload settings or re-resolve the referenced
object. SupportsFixAll = true; FixAll routes through
WellKnownFixAllProviders.BatchFixer.

Compatibility
-------------
Full netstandard2.1 support — no net8.0-exclusive SDK APIs are used.

Tests
-----
LC0098 project contributes 15 fixtures; ALCops.Common adds unit tests for
the "original casing wins" renderer semantics.

Closes ALCops#406
…asing as preferred

Add accepted-name set handling for uppercase acronym words (for example LCY and Lcy), keep CodeFix suggestion on original casing, and extend renderer/analyzer tests plus schema/instruction docs.

@Arthurvdv Arthurvdv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #408 Review — LC0098 EventSubscriberNamingPattern

Summary: Solid implementation overall. Built on net10.0 and netstandard2.1, all 33 LC0098 analyzer/CodeFix tests pass, but 2 of the PR's own new unit tests fail, and the collision guard is case-sensitive where the AL compiler is case-insensitive.

🔴 Critical — PR's own AcronymRegistry tests fail: duplicate case-insensitive keys in DefaultAcronyms

src/ALCops.Common/Helpers/AcronymRegistry.cs:35,40
DefaultAcronyms contains both "Bom"/"BoM" and "Uom"/"UoM". The registry keys on ToUpperInvariant() with last-entry-wins, so "Bom"/"Uom" are dead entries. dotnet test on the PR head: Failed: 2 (Default_ExposesEveryEntryFromDefaultAcronyms, Create_WithNullUserList_ReturnsDefaultsOnlyExpected: "Bom" But was: "BoM").
Fix: pick one canonical casing per key, or adjust the tests, but as written the duplicates are pointless since only one survives Create().

🟠 Major — Collision guard is case-sensitive; AL duplicate-method detection is case-insensitive

src/ALCops.LinterCop/Analyzers/EventSubscriberNamingPattern.cs:176,187
WouldCollideInContainingType compares sibling names with StringComparison.Ordinal. If a sibling is named mypublisher_onsomething() and the preferred name is MyPublisher_OnSomething, the guard misses it and applying the CodeFix produces a duplicate-method compile error. SDK's MethodSymbol.HasSameSignature uses SemanticFacts.IsSameName (case-insensitive), matching repo convention. (The ordinal check at line 74 is correct — that one intentionally enforces casing.)
Fix: use SemanticFacts.IsSameName() for both collision checks.

🔵 Minor — Resx description documents a wrong default template

src/ALCops.LinterCop/ALCops.LinterCopAnalyzers.resx:430
Says '{Event Source}_{EventName}[{Element Name}]'; actual default is '{Event Source}_{Event Name}[_{Element Name}]'. Placeholder spelling is semantically significant (PascalCase vs raw), and the _ in the optional group matters. Schema description is correct.

✅ Conventions verified OK

Analyzer structure (matches LC0092/LC0097), EnumProvider usage, IsObsolete, attribute argument extraction (indices match SDK EventSubscriber order, quoted names covered), per-compilation caching/performance, template parser robustness (unclosed braces/brackets degrade gracefully, bounded cross-product), netstandard2.1 (builds clean, correct #if pattern), CodeFix structure (BatchFixer, properties handoff, trivia preserved), descriptor/resx/schema/settings patterns, all 33 LC0098 tests pass with valid markers.


Generated with Claude Fable 5 via GitHub Copilot CLI

- AcronymRegistry: store ordered variants per upper-invariant key, add
  TryGetVariants; user entries displace defaults per key; reorder
  DefaultAcronyms so canonical (BoM, UoM) precedes additional variants
- IdentifierNameRenderer: HasAnyUpper branch accepts every registered
  variant alongside the source's original casing
- EventSubscriberNamingPattern: WouldCollideInContainingType now uses
  SemanticFacts.IsSameName; AL treats duplicate methods case-insensitively
- Resx: correct default template placeholders to
  {Event Source}_{Event Name}[_{Element Name}]
- Tests: +4 multi-variant registry tests, +1 case-insensitive collision
  regression fixture
@MODUSCarstenScholling

Copy link
Copy Markdown
Contributor Author

Addresses three review findings on LC0098 (EventSubscriberNamingPattern) and its shared AcronymRegistry infrastructure.

1. AcronymRegistry: multi-variant support per case-insensitive key

Previously, DefaultAcronyms listed "Bom"/"BoM" and "Uom"/"UoM" as separate entries, but the underlying Dictionary<string, string> collapsed each pair to a single last-write-wins survivor. This caused two AcronymRegistryTests to fail on the PR head (Expected: "Bom" But was: "BoM").

The registry now stores an ordered list of registered variants per upper-invariant key:

  • Storage changed from Dictionary<string, string> to Dictionary<string, List<string>>.
  • New public API: TryGetVariants(word, out IReadOnlyList<string> variants).
  • TryGetCanonical unchanged in signature; now returns the first-added variant for the key.
  • User entries displace built-in variants for their key (preserves existing override semantics); multiple user entries under the same key accumulate in order.
  • DefaultAcronyms reordered so the canonical variant precedes additional variants ("BoM", "Bom", "UoM", "Uom").

IdentifierNameRenderer.GetWordAlternatives (HasAnyUpper branch) now consumes TryGetVariants and appends every registered variant that differs from the primary. Result: subscribers can list multiple acceptable spellings for the same acronym (e.g. BoM + Bom both accepted alongside the source's own casing). The all-lowercase branch is unchanged.

Config and defaults remain flat arrays — no schema change.

2. Collision guard: case-insensitive AL identifier comparison

WouldCollideInContainingType compared sibling names with StringComparison.Ordinal. AL treats duplicate method identifiers case-insensitively (AL0018), so an only-case-different sibling (mypublisher_onsomething vs. suggested MyPublisher_OnSomething) was not detected and the CodeFix would produce a duplicate-identifier compile error.

Both sibling comparisons now use SemanticFacts.IsSameName (repo convention per analyzer-development.instructions.md). The rule-enforcement comparison at the top of Analyze (accepted-set membership) intentionally stays StringComparison.Ordinal — enforcing exact casing is the rule's purpose.

Regression fixture: NoDiagnostic/PreferredNameCollidesWithSiblingDifferentCase.al.

3. Resx: default template placeholder spelling

EventSubscriberNamingPatternDescription documented the default template as '{Event Source}_{EventName}[{Element Name}]'. Actual default (and schema description) is '{Event Source}_{Event Name}[_{Element Name}]'. Placeholder spelling is semantically significant (raw vs PascalCase), and the _ inside the optional group matters. Corrected.

Test results

  • ALCops.Common.Test: 68/68 passing (was 66/68 failing on PR head; +4 new multi-variant tests, −2 removed per-entry assertions replaced).
  • ALCops.LinterCop.Test (EventSubscriberNamingPattern filter): 34/34 passing (+1 regression case).

Compatibility

  • Public API additive: new TryGetVariants; TryGetCanonical signature and semantics unchanged for keys with a single variant.
  • alcops.json KnownAcronyms schema unchanged (flat array).
  • LC0098 accepts a strict superset of previous spellings when defaults or user config list multiple variants; no previously-accepted spelling is now rejected.

@Arthurvdv

Copy link
Copy Markdown
Member

Thans for looking into the feedback. Let's get this merged :-)

@Arthurvdv
Arthurvdv merged commit b91d227 into ALCops:main Jul 20, 2026
49 checks passed
@MODUSCarstenScholling
MODUSCarstenScholling deleted the dev-cs-new-subscriber-naming-template branch July 21, 2026 00:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants