From cb589c2c148f147e95befb0232b506f89e25d5dc Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Tue, 1 Sep 2026 14:12:23 +0200 Subject: [PATCH 1/2] fix(LC0089): pass compilation-scoped settings via closure instead of instance fields CognitiveComplexity stored the complexity threshold and the LC0089i enablement flag in mutable instance fields written at CompilationStart. Analyzer instances are shared across analysis passes and projects, so an overlapping pass with a different alcops.json or ruleset could overwrite them mid-analysis, applying the wrong threshold or increment enablement. Capture both as locals in the CompilationStart closure (like the existing recursion graph service) and thread them as parameters. No functional change under single-pass analysis; the race is not reproducible in unit tests, so no new fixture. Found while verifying issue #254. Co-Authored-By: Claude Fable 5 --- .../Analyzers/CognitiveComplexity.cs | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/src/ALCops.LinterCop/Analyzers/CognitiveComplexity.cs b/src/ALCops.LinterCop/Analyzers/CognitiveComplexity.cs index 89a6b948..4b8f1dd6 100644 --- a/src/ALCops.LinterCop/Analyzers/CognitiveComplexity.cs +++ b/src/ALCops.LinterCop/Analyzers/CognitiveComplexity.cs @@ -12,9 +12,6 @@ namespace ALCops.LinterCop.Analyzers; [DiagnosticAnalyzer] public sealed class CognitiveComplexity : DiagnosticAnalyzer { - private int complexityThreshold; - private bool IsIncrementDiagnosticsEnabled; - // Flow-Breaking Structures: These disrupt the linear execution of the code. // Each occurrence of these structures adds +1 complexity to the score. private static readonly HashSet flowBreakingKinds = new() @@ -81,19 +78,18 @@ public override void Initialize(AnalysisContext context) context.RegisterCompilationStartAction(compilationContext => { var compilation = compilationContext.Compilation; - this.complexityThreshold = LoadCognitiveComplexityThreshold(compilation); - this.IsIncrementDiagnosticsEnabled = compilation.IsDiagnosticEnabled(DiagnosticDescriptors.CognitiveComplexityIncrement); + var complexityThreshold = LoadCognitiveComplexityThreshold(compilation); + var isIncrementDiagnosticsEnabled = compilation.IsDiagnosticEnabled(DiagnosticDescriptors.CognitiveComplexityIncrement); var recursion = new CognitiveComplexityRecursionGraphService(compilation); - compilationContext.RegisterCodeBlockAction(codeBlockContext => { - AnalyzeCognitiveComplexity(codeBlockContext, recursion); + AnalyzeCognitiveComplexity(codeBlockContext, recursion, complexityThreshold, isIncrementDiagnosticsEnabled); }); }); } - private void AnalyzeCognitiveComplexity(CodeBlockAnalysisContext context, CognitiveComplexityRecursionGraphService recursion) + private static void AnalyzeCognitiveComplexity(CodeBlockAnalysisContext context, CognitiveComplexityRecursionGraphService recursion, int complexityThreshold, bool isIncrementDiagnosticsEnabled) { if (context.IsObsolete() || context.CodeBlock is not MethodOrTriggerDeclarationSyntax methodOrTrigger) return; @@ -108,7 +104,7 @@ private void AnalyzeCognitiveComplexity(CodeBlockAnalysisContext context, Cognit methodOrTrigger.Attributes.Any(attr => eventPublisherDecoratorNames.Contains(attr.GetIdentifierOrLiteralValue() ?? string.Empty))) return; - int complexity = CalculateCognitiveComplexity(context, recursion, methodOrTrigger.Body); + int complexity = CalculateCognitiveComplexity(context, recursion, methodOrTrigger.Body, isIncrementDiagnosticsEnabled); if (complexity >= complexityThreshold) { context.ReportDiagnostic(Diagnostic.Create( @@ -125,7 +121,7 @@ private void AnalyzeCognitiveComplexity(CodeBlockAnalysisContext context, Cognit complexityThreshold)); } - private int CalculateCognitiveComplexity(CodeBlockAnalysisContext context, CognitiveComplexityRecursionGraphService recursion, SyntaxNode root) + private static int CalculateCognitiveComplexity(CodeBlockAnalysisContext context, CognitiveComplexityRecursionGraphService recursion, SyntaxNode root, bool isIncrementDiagnosticsEnabled) { int complexity = 0; var stack = new Stack<(SyntaxNode node, int nestingLevel)>(); @@ -137,14 +133,14 @@ private int CalculateCognitiveComplexity(CodeBlockAnalysisContext context, Cogni if (node.IsKind(EnumProvider.SyntaxKind.IfStatement)) { - ProcessIfStatement(context, ref stack, node, ref complexity, ref nestingLevel); + ProcessIfStatement(context, ref stack, node, ref complexity, ref nestingLevel, isIncrementDiagnosticsEnabled); continue; // Skip further processing for this IF node } if (IsFlowBreakingStructure(node) && !IsGuardClause(node)) { complexity += 1 + nestingLevel; - RaiseIncrementDiagnostic(context, GetKeywordLocation(node, node.SpanStart), node.Kind.ToString(), nestingLevel); + RaiseIncrementDiagnostic(context, GetKeywordLocation(node, node.SpanStart), node.Kind.ToString(), nestingLevel, isIncrementDiagnosticsEnabled); if (IsNestedStructure(node)) nestingLevel++; @@ -158,7 +154,7 @@ private int CalculateCognitiveComplexity(CodeBlockAnalysisContext context, Cogni if (context.CodeBlock.IsKind(EnumProvider.SyntaxKind.MethodDeclaration)) { - complexity += CalculateRecursionComplexity(context, recursion, root); + complexity += CalculateRecursionComplexity(context, recursion, root, isIncrementDiagnosticsEnabled); } return complexity; @@ -168,7 +164,7 @@ private int CalculateCognitiveComplexity(CodeBlockAnalysisContext context, Cogni // In the AL Language 'else if' is an 'else" keyword followed by an 'if' node (not a single 'elsif' node). // If we increment for both 'else' and 'if' kinds the number will be too high. // So we'll increment for 'else' nodes not followed by an 'if' and rely on the 'if' to increment 'else if' statements. - private void ProcessIfStatement(CodeBlockAnalysisContext context, ref Stack<(SyntaxNode, int)> stack, SyntaxNode node, ref int complexity, ref int nestingLevel) + private static void ProcessIfStatement(CodeBlockAnalysisContext context, ref Stack<(SyntaxNode, int)> stack, SyntaxNode node, ref int complexity, ref int nestingLevel, bool isIncrementDiagnosticsEnabled) { if (node is not IfStatementSyntax ifStatement) return; @@ -177,7 +173,7 @@ private void ProcessIfStatement(CodeBlockAnalysisContext context, ref Stack<(Syn { // Increment for the 'if' statement complexity += 1 + nestingLevel; - RaiseIncrementDiagnostic(context, GetKeywordLocation(node, node.SpanStart), node.Kind.ToString(), nestingLevel); + RaiseIncrementDiagnostic(context, GetKeywordLocation(node, node.SpanStart), node.Kind.ToString(), nestingLevel, isIncrementDiagnosticsEnabled); } // Push the condition of the 'if' statement back to the stack @@ -195,7 +191,7 @@ private void ProcessIfStatement(CodeBlockAnalysisContext context, ref Stack<(Syn { // Increment for the 'else' statement complexity += 1 + nestingLevel; - RaiseIncrementDiagnostic(context, ifStatement.ElseKeywordToken.GetLocation(), "ElseStatement", nestingLevel); + RaiseIncrementDiagnostic(context, ifStatement.ElseKeywordToken.GetLocation(), "ElseStatement", nestingLevel, isIncrementDiagnosticsEnabled); // increment nesting for subsequent statements nestingLevel += 1; @@ -276,7 +272,7 @@ private static bool IsGuardCommand(MemberAccessExpressionSyntax memberAccess) #region Recursion - private int CalculateRecursionComplexity(CodeBlockAnalysisContext context, CognitiveComplexityRecursionGraphService recursion, SyntaxNode root) + private static int CalculateRecursionComplexity(CodeBlockAnalysisContext context, CognitiveComplexityRecursionGraphService recursion, SyntaxNode root, bool isIncrementDiagnosticsEnabled) { if (recursion is null) return 0; @@ -311,7 +307,7 @@ private int CalculateRecursionComplexity(CodeBlockAnalysisContext context, Cogni if (IsPathTo(recursion, invokedMethod.Id, currentId, visited)) { increment++; - RaiseIncrementDiagnostic(context, GetKeywordLocation(target, target.SpanStart), "RecursionCycle", 0); + RaiseIncrementDiagnostic(context, GetKeywordLocation(target, target.SpanStart), "RecursionCycle", 0, isIncrementDiagnosticsEnabled); } } @@ -348,9 +344,9 @@ private static int LoadCognitiveComplexityThreshold(Compilation compilation) return settings.CognitiveComplexityThreshold; } - private void RaiseIncrementDiagnostic(CodeBlockAnalysisContext context, Location location, string category, int nestingPenalty) + private static void RaiseIncrementDiagnostic(CodeBlockAnalysisContext context, Location location, string category, int nestingPenalty, bool isIncrementDiagnosticsEnabled) { - if (!this.IsIncrementDiagnosticsEnabled) + if (!isIncrementDiagnosticsEnabled) return; context.ReportDiagnostic( From 3927b8a12b5e95bd1ba2c59ce96a4ff21cf909f6 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Tue, 1 Sep 2026 14:12:24 +0200 Subject: [PATCH 2/2] docs: correct the incremental-analysis model, add LC0089 rule doc Verification of issue #254 against the decompiled NAV SDK showed the documented declaration-cache skipping (AnalysisState.declarationAnalysisDataMap) is dead code: every shipping driver path passes a null AnalysisState, so no per-declaration action is ever cache-skipped, and there is no SyntaxNodeAction-vs-CodeBlockAction asymmetry. The real callback filters are the per-file AnalysisScope and the module-only partial pass, both uniform across action kinds - which is also the actual mechanism behind the #243 AC0032 false positives. The two-phase accumulator anti-pattern verdict stands, re-justified. Also records the CognitiveComplexity design decisions in a new rule doc. Co-Authored-By: Claude Fable 5 --- .../lc0089-cognitive-complexity.md | 33 ++++++++++++ .claude/rules/sdk-analyzer-infrastructure.md | 50 +++++++++++-------- CLAUDE.md | 2 +- 3 files changed, 63 insertions(+), 22 deletions(-) create mode 100644 .claude/rules/diagnostics/lc0089-cognitive-complexity.md diff --git a/.claude/rules/diagnostics/lc0089-cognitive-complexity.md b/.claude/rules/diagnostics/lc0089-cognitive-complexity.md new file mode 100644 index 00000000..0f99e358 --- /dev/null +++ b/.claude/rules/diagnostics/lc0089-cognitive-complexity.md @@ -0,0 +1,33 @@ +--- +paths: + - "src/ALCops.LinterCop/**/*CognitiveComplexity*" +--- + +# LC0089 / LC0089i / LC0090: CognitiveComplexity + +## Purpose + +LC0089 reports a hidden metric diagnostic with the cognitive complexity score of each method/trigger. LC0089i (opt-in) reports per-increment diagnostics showing where each complexity point comes from and the nesting penalty. LC0090 reports a warning when cognitive complexity exceeds the configurable threshold. Together they give AL developers actionable feedback on method readability using the Cognitive Complexity model (Sonar). + +## Design decisions + +| Decision | Rationale | +|---|---| +| `CompilationStart` closure for threshold and LC0089i enablement (not instance fields) | Analyzer instances are shared across passes/projects (`ProjectInfo.cs:113`). Mutable instance fields are overwritten by an overlapping pass with a different `alcops.json` or ruleset, producing wrong thresholds or silently enabling/disabling LC0089i mid-analysis. Fixed in #254. | +| Threshold loaded from `ALCopsSettingsProvider`, not per-method | `ALCopsSettingsProvider` caches statically by directory path with no invalidation API — an `alcops.json` edit takes effect only after restart regardless of where settings are read. Moving the read into a per-method callback would add cost without changing staleness behavior. | +| `RegisterCodeBlockAction` (not `SyntaxNodeAction` or `OperationAction`) | Cognitive complexity requires walking the full method body once with nesting tracking. `CodeBlockAction` provides the body directly and benefits from pre-computed operation trees for recursion detection. | +| Recursion detection via `CognitiveComplexityRecursionGraphService` | Builds a call graph at `CompilationStart` from all method bodies, then queries it per-method for cycles. Lives in a separate service class because the graph is compilation-scoped while the complexity walk is per-method. | +| LC0089 as `Info` / `isEnabledByDefault: false` | A metric, not a violation — only useful to developers who opt in via `.editorconfig`. | +| LC0089i as `Info` / `isEnabledByDefault: false` | Per-increment detail is noise unless actively investigating a specific method's score. Gated behind `IsDiagnosticEnabled` so the walk-and-report cost is zero when disabled. | +| Guard-clause discount | Standard Cognitive Complexity model: early-exit `if then exit/error/break/continue/skip/quit` is a flow simplification, not additional cognitive load, so it does not increment. | +| #254 verification: no regression fixture for the instance-field race | The race requires overlapping compilation passes against different `alcops.json` files, which is not reproducible in unit tests. Existing `CognitiveComplexity` tests verify the functional behavior is unchanged. | + +## Architecture + +- Registers `CompilationStartAction` → captures `complexityThreshold`, `isIncrementDiagnosticsEnabled`, and `CognitiveComplexityRecursionGraphService` as locals → registers `CodeBlockAction`. +- Iterative stack-based walk (no recursion) of the method body counts flow-breaking structures, nesting penalties, logical-operator sequences, and guard-clause discounts. +- Recursion detection: `CognitiveComplexityRecursionGraphService` builds an adjacency list of method-ID edges at compilation start; per-method, checks for cycles back to the current method via DFS. + +## Known issues + +- `ALCopsSettingsProvider` caches the threshold statically by directory with no invalidation. Changing `alcops.json` requires restarting the language server. This is a pre-existing cross-cop limitation, not specific to this analyzer. diff --git a/.claude/rules/sdk-analyzer-infrastructure.md b/.claude/rules/sdk-analyzer-infrastructure.md index dc706944..104d9d0a 100644 --- a/.claude/rules/sdk-analyzer-infrastructure.md +++ b/.claude/rules/sdk-analyzer-infrastructure.md @@ -19,31 +19,29 @@ Source: `AnalyzerDriver.cs` lines 284-365 (`TryExecuteDeclaringReferenceActions` Consequence: `GetOperation(body)` called from a `SyntaxNodeAction` runs BEFORE the SDK pre-computes operation trees, while `GetOperation(body)` in a `CodeBlockAction` benefits from pre-computation (effectively a cache hit). -## Incremental compilation and callback skipping +## Partial-analysis pass and callback scope filtering -The SDK uses `AnalysisState.declarationAnalysisDataMap` to cache which declarations have been analyzed. During incremental compilation (e.g., editing a file in VS Code): +The SDK's `AnalysisState.declarationAnalysisDataMap` (declaration-level diagnostic caching and replay) exists in the source but is **dead code in all shipping paths**. `CompilationWithAnalyzers` — the only owner of `AnalysisState` — is never instantiated anywhere in the SDK tree. Both driver entry points (`AnalyzerDriverBase.cs:371-379` whole-project, `:381-394` per-file) pass a **null** `AnalysisState` and a fresh `CompilationData`. Every skip gate evaluates `analysisStateOpt?.TryStartAnalyzingDeclaration(...) ?? true` (`AnalyzerExecutor.cs:996-1001`) → always execute. `declarationAnalysisDataMap` lives on `CompilationData` (not `AnalysisState`), holds only syntax-shape data (no diagnostics), and its `cacheAnalysisData` flag evaluates false on every real path (`AnalyzerDriver.cs:274-275`). There is **no per-declaration action-kind asymmetry**: SyntaxNode, Operation, OperationBlock, and CodeBlock actions all gate on the identical `TryStartAnalyzingDeclaration` call (`AnalyzerExecutor.cs:983 / 1086 / 1110 / 1128`); if skipping were ever activated they'd be skipped in lockstep. -- **`CodeBlockAction`** callbacks are SKIPPED for cached (unchanged) declarations -- **`CodeBlockStartAction`** callbacks are ALSO SKIPPED (same cache mechanism) -- **`CompilationEndAction`** ALWAYS fires regardless of what was skipped -- **`SyntaxNodeAction`** tracks per-node via `ProcessedNodes` (lines 641-645): if the object node is skipped, no analysis runs and no stale results exist +The real mechanism that filters callbacks is host-level partial-analysis scope: -Source: `AnalyzerExecutor.cs` lines 527-530 (`ShouldExecuteAction`), 881-887. +- **Per-file keystroke pass:** the host builds an `AnalysisScope` with `FilterTreeOpt` = the edited file. `ShouldAnalyze(ISymbol)` (`AnalysisScope.cs:80-100`) rejects every declaration outside that file — for **all** per-declaration action kinds uniformly — while compilation-level events still complete. +- **Module-only pass:** when `BackgroundCodeAnalysisScope == File` and doc count exceeds `PartialDiagnosticsDocumentThreshold`, `GetPerModuleAnalyzerDiagnostics` enqueues only a module `SymbolDeclaredCompilationEvent` (`AnalyzersHelper.cs:70-78`) — no per-declaration action of any kind fires; compilation-level actions still complete. +- **Hash-suppressor staleness:** `vsCodeDiagnosticState` in `EditorServices.Protocol/DiagnosticService.cs:715-736` drops the response when the file hash is unchanged; `moduleAnalyzerDiagnosticsCache` is invalidated on Start/Stop/DocumentRemoved but not on settings/ruleset changes. + +Source: verified against the decompiled SDK at the current tag (18.0.x, net10.0 tree). ### Implications for analyzer patterns -| Pattern | Correct under incremental? | Notes | +| Pattern | Correct under partial analysis? | Notes | |---|---|---| -| `RegisterSyntaxNodeAction` on object kinds | ✅ Yes | Either full analysis runs or none; no partial state | +| Self-contained per-declaration action (any kind) | ✅ Yes | SyntaxNode, Operation, CodeBlock actions are equally safe when each callback reports only about its own declaration | | `RegisterSymbolAction` | ✅ Yes | Symbol-level, self-contained | -| `RegisterOperationAction` | ✅ Yes | Per-invocation, self-contained | -| `RegisterCodeBlockAction` | ⚠️ Risky | Can be skipped for cached declarations | -| `RegisterCodeBlockStartAction` + `CodeBlockEndAction` | ⚠️ Risky | Same skipping mechanism as CodeBlockAction | -| `CompilationStart` + accumulator + `CompilationEnd` | ❌ Broken | Accumulator is incomplete when CodeBlockActions are skipped | +| `CompilationStart` + per-declaration accumulator + `CompilationEnd` | ❌ Broken | Under per-file or module-only passes, per-declaration callbacks run only for the edited file (or none); `CompilationEnd` fires with an incomplete/empty accumulator | ### The two-phase accumulator anti-pattern -**NEVER** use this pattern for analyzers that need per-object completeness: +**NEVER** use this pattern for analyzers that need cross-declaration completeness: ```csharp // BROKEN PATTERN - DO NOT USE @@ -53,19 +51,26 @@ context.RegisterCompilationStartAction(startCtx => startCtx.RegisterCodeBlockAction(blockCtx => { - // This callback is SKIPPED for cached declarations! + // Under per-file pass: only fires for declarations in the edited file + // Under module-only pass: never fires at all accumulator.TryAdd(...); }); startCtx.RegisterCompilationEndAction(endCtx => { - // This ALWAYS fires, even with incomplete accumulator! + // Always fires, but accumulator is incomplete or empty foreach (var entry in accumulator) { ... } }); }); ``` -Microsoft's own analyzers never use this pattern for the same reason. Their `Rule175` uses `CodeBlockStartAction` + scoped `RegisterSyntaxNodeAction` + `CodeBlockEndAction` for per-method analysis, but only reports within that method (no cross-method accumulation). +This is what caused #243/#253: AC0032's old accumulator contained only the edited file's usages when `CompilationEnd` fired under the per-file pass, so permission entries from other files were flagged as "unused". A `SyntaxNodeAction`-based accumulator would have failed identically — the fix worked because each object became self-contained, not because of an action-kind difference. + +Microsoft's own analyzers never use this pattern. Their `Rule175` uses `CodeBlockStartAction` + scoped `RegisterSyntaxNodeAction` + `CodeBlockEndAction` for per-method analysis, but only reports within that method (no cross-method accumulation). + +### Analyzer instances are shared — no mutable instance fields + +Analyzer instances are materialized once per project (`ProjectInfo.cs:113`) and shared across passes. Per-compilation state (settings, thresholds, enablement flags) must live in `CompilationStart` closures or be threaded as parameters, never stored in instance fields. An overlapping pass or different project with a different `alcops.json` or ruleset would overwrite instance fields mid-analysis. See also `analyzer-development.md` ("Pass loaded data via lambda captures or a state object, not instance fields"). ## GetOperation performance characteristics @@ -201,12 +206,15 @@ It is NOT safe for accumulating mutable state across `CodeBlockAction` callbacks | File | Key content | |---|---| -| `AnalyzerExecutor.cs:527-530` | `ShouldExecuteAction` - skipping cached declarations | -| `AnalyzerExecutor.cs:641-645` | SyntaxNodeAction per-node tracking | -| `AnalyzerExecutor.cs:881-887` | `ShouldExecuteAction` method definition | +| `AnalyzerExecutor.cs:996-1001` | `TryStartAnalyzingDeclaration` skip gate (null `AnalysisState` → always execute) | +| `AnalyzerExecutor.cs:983 / 1086 / 1110 / 1128` | Identical skip gate for SyntaxNode, Operation, OperationBlock, CodeBlock actions | +| `AnalyzerDriverBase.cs:371-394` | Both driver entry points pass null `AnalysisState` | +| `AnalyzerDriver.cs:274-275` | `cacheAnalysisData` flag evaluates false on all real paths | | `AnalyzerDriver.cs:284-365` | Guaranteed execution order (SyntaxNode → Operation → CodeBlock) | | `AnalyzerDriver.cs:504-518` | `GetOperationBlocksToAnalyze` pre-computation | -| `AnalysisState.cs` | `declarationAnalysisDataMap` cache | +| `AnalysisScope.cs:80-100` | `ShouldAnalyze(ISymbol)` per-file scope filtering | +| `AnalyzersHelper.cs:70-78` | Module-only pass: enqueues only `SymbolDeclaredCompilationEvent` | +| `ProjectInfo.cs:113` | Analyzer instances materialized once per project (shared across passes) | | `SemanticModel.cs:43-45` | `GetSymbolInfo(SyntaxNode)` public API | | `SemanticModel.cs:302` | `GetSymbolInfo(ExpressionSyntax)` public API | | `SemanticModel.cs:1130` | `GetOperation(SyntaxNode)` public API | diff --git a/CLAUDE.md b/CLAUDE.md index c602a5dc..ab8e4ea2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ dotnet test src/ALCops.LinterCop.Test/ --filter "FullyQualifiedName~{RuleName}.H - **Read the decompiled NAV SDK source before using any SDK API.** Syntax kinds, operation shapes, and symbol members are undocumented and version-dependent. See `.claude/rules/analyzer-development.md` (§NAV SDK Source Reference) and `.claude/rules/sdk-analyzer-infrastructure.md`. - **Every analyzer must compile on `netstandard2.1`.** Guard newer C# features and missing SDK APIs; net8.0-only analyzers compile as empty stubs under `#if NETSTANDARD2_1`. See `.claude/rules/netstandard21-compatibility.md`. -- **Never assume analyzer callback ordering or that every callback runs** (incremental compilation skips them). No two-phase accumulator patterns. See `.claude/rules/sdk-analyzer-infrastructure.md`. +- **Never assume analyzer callback ordering or that every callback runs** (the host's partial-analysis module pass skips all per-declaration callbacks). No two-phase accumulator patterns. See `.claude/rules/sdk-analyzer-infrastructure.md`. - **Analyzers extend plain `DiagnosticAnalyzer`.** Do not switch them to the `ALCopsDiagnosticAnalyzer` / `{Cop}Analyzer` exception harness: deriving from a Common-based type makes `alc` fail with `AL1003` (issue #389). The harness stays test-only until a loader-safe approach exists. See `.claude/rules/analyzer-exception-harness.md`. - **`ALCopsSettings.cs` and `alcops.schema.json` must stay in sync**; a parity test enforces it. See `.claude/rules/settings-schema.md`. - Diagnostic IDs are `{Prefix}{4 digits}`, sequential per cop. Help URI: `https://alcops.dev/docs/analyzers/{copslug}/{id}/`. Every new rule needs a page in the sibling docs repo (`../alcops.dev`, `content/docs/analyzers/{copslug}/{ID}.md`).