diff --git a/docs/ANcpLua.Analyzers.md b/docs/ANcpLua.Analyzers.md index 0b77ab7..fe91e61 100644 --- a/docs/ANcpLua.Analyzers.md +++ b/docs/ANcpLua.Analyzers.md @@ -6,7 +6,7 @@ Roslyn analyzers + code fixes covering modern C# correctness pitfalls, ASP.NET C ## Package family -- **[ANcpLua.Analyzers](https://github.com/ANcpLua/ANcpLua.Analyzers)** — this package; the `AL00xx`–`AL18xx` Roslyn diagnostics. +- **[ANcpLua.Analyzers](https://github.com/ANcpLua/ANcpLua.Analyzers)** — this package; the `AL10xx`–`AL18xx` Roslyn diagnostics. - **[ANcpLua.NET.Sdk](https://github.com/ANcpLua/ANcpLua.NET.Sdk)** — MSBuild SDK that auto-injects this analyzer + the bundled `editorconfig` severity profile. - **[ANcpLua.Roslyn.Utilities](https://github.com/ANcpLua/ANcpLua.Roslyn.Utilities)** — shared Roslyn helpers + the `Guard.*` API the `AL12xx` band promotes. @@ -108,7 +108,7 @@ Each ID links to a per-rule page under [`docs/rules/`](rules/) with severity, ca ## Consumer-side severity profile (`AlAnalysisMode`) -Set `` in your csproj to switch the whole `AL00xx`–`AL18xx` band in one line instead of dropping editorconfig files: +Set `` in your csproj to switch the whole `AL10xx`–`AL18xx` band in one line instead of dropping editorconfig files: ```xml @@ -127,9 +127,10 @@ The property is exposed via the analyzer NuGet's `buildTransitive/ANcpLua.Analyz ## See also -- [Per-rule pages](rules/) — one markdown file per `AL00xx`–`AL18xx` rule with severity, category, code-fix status, and description. +- [Per-rule pages](rules/) — one markdown file per `AL10xx`–`AL18xx` rule with severity, category, code-fix status, and description. - [Editorconfig profiles](editorconfig/) — three drop-in severity profiles: `Default`, `AllRulesAsErrors`, `AllRulesDisabled`. Same content ships inside the NuGet under `buildTransitive/editorconfig/`. -- [SARIF rule manifest](ANcpLua.Analyzers.sarif) — SARIF v2.1.0 catalog of every `AL00xx`–`AL18xx` rule (id, name, severity, category, helpUri). Consume from Sonar bridges, GitHub Advanced Security uploads, or IDE rule-catalog tools. +- [SARIF rule manifest](ANcpLua.Analyzers.sarif) — SARIF v2.1.0 catalog of every `AL10xx`–`AL18xx` rule (id, name, severity, category, helpUri). Consume from Sonar bridges, GitHub Advanced Security uploads, or IDE rule-catalog tools. +- [Migration catalog](migration-catalog.md) — the `AL0xxx` → `AL1xxx` rename map from the 2.0.0 break. Use to resolve stale `dotnet_diagnostic.AL0xxx.severity = …` editorconfig entries. - [`AnalyzerReleases.Unshipped.md`](../src/ANcpLua.Analyzers/AnalyzerReleases.Unshipped.md) — release-tracking manifest with `ClassName` attribution per Microsoft NetAnalyzers convention. ## Generated File diff --git a/docs/migration-catalog.md b/docs/migration-catalog.md new file mode 100644 index 0000000..7685a7e --- /dev/null +++ b/docs/migration-catalog.md @@ -0,0 +1,140 @@ +# AL Analyzer Renumber Catalog + + + +AL0xxx → AL1xxx rename map from the ANcpLua.Analyzers 2.0.0 break. The 2.0.1 analyzer no longer emits any `AL0xxx` ID, so consumers carrying `dotnet_diagnostic.AL0xxx.severity = …` entries from an older version need this table to find the replacement. + +## Summary + +`89` renames across `9` 100-wide domain bands. The `AL0xxx` range is reserved for sibling packages (`ANcpLua.AotReflection`, `ANcpLua.ExtensibleEnumMirror`, `ANcpLua.DiscriminatedUnion`) and is not reissued by this analyzer. + +## Completion audit + +`AlIdMigrationCatalog.Validate()` enforces these invariants on every generator run + on every CI build (via `AnalyzerConventionTests.AlIdMigrationCatalog_StructuralInvariants_Hold`): + +- No duplicate `OldId` across the catalog. +- No duplicate `NewId` across the catalog. +- Every `NewId` matches `^AL1[0-8]\d{2}$` — sits inside the post-renumber band `AL1000..AL1899`. +- Every `OldId` matches `^AL\d{4}$` — well-formed legacy diagnostic ID. + +No hardcoded `ExpectedCount` constant — count is a consequence of the invariants, not a property worth asserting on its own. + +## Renames by band + +| Band | Count | +| -- | --: | +| AL1000..AL1099 Correctness | 13 | +| AL1100..AL1199 ASP.NET Core | 10 | +| AL1200..AL1299 Roslyn Utilities | 21 | +| AL1300..AL1399 Async / reliability | 15 | +| AL1400..AL1499 AOT / trim | 10 | +| AL1500..AL1599 Roslyn-author hygiene | 6 | +| AL1600..AL1699 Package / version | 7 | +| AL1700..AL1799 Style | 4 | +| AL1800..AL1899 Agent governance | 3 | + +## Old → New mapping + +Sorted by `NewId` — scan to find your current ID's predecessor. + +| Old ID | New ID | Title | +| -- | -- | -- | +| `AL0001` | `AL1000` | Prohibit reassignment of primary constructor parameters | +| `AL0002` | `AL1001` | Don't repeat negated patterns | +| `AL0003` | `AL1002` | Don't divide by constant zero | +| `AL0004` | `AL1003` | Use pattern matching when comparing Span with constants | +| `AL0005` | `AL1004` | Use SequenceEqual when comparing Span with non-constants | +| `AL0006` | `AL1005` | Field name conflicts with primary constructor parameter | +| `AL0007` | `AL1006` | GetSchema should be explicitly implemented | +| `AL0008` | `AL1007` | GetSchema must return null and not be abstract | +| `AL0009` | `AL1008` | Don't call IXmlSerializable.GetSchema | +| `AL0011` | `AL1009` | Avoid lock keyword on non-Lock types | +| `AL0014` | `AL1010` | Prefer pattern matching for null and zero comparisons | +| `AL0015` | `AL1011` | Normalize null-guard style | +| `AL0016` | `AL1012` | Combine declaration with subsequent null-check | +| `AL0020` | `AL1100` | IFormCollection requires explicit attribute | +| `AL0021` | `AL1101` | Multiple structured form sources | +| `AL0022` | `AL1102` | Mixed form collection and DTO | +| `AL0023` | `AL1103` | Unsupported form type | +| `AL0024` | `AL1104` | Form and body conflict | +| `AL0080` | `AL1105` | Missing resilience configuration | +| `AL0081` | `AL1106` | Missing health checks | +| `AL0082` | `AL1107` | Consider using configuration for connection string | +| `AL0084` | `AL1108` | Missing service discovery | +| `AL0106` | `AL1109` | Avoid Task.Run in ASP.NET Core request handlers | +| `AL0028` | `AL1200` | Use IsEqualTo extension | +| `AL0029` | `AL1201` | Use HasAttribute extension | +| `AL0030` | `AL1202` | Use type hierarchy extension | +| `AL0031` | `AL1203` | Use operation extension | +| `AL0032` | `AL1204` | Use OrEmpty extension | +| `AL0033` | `AL1205` | Use ToImmutableArrayOrEmpty extension | +| `AL0034` | `AL1206` | Use WhereNotNull extension | +| `AL0035` | `AL1207` | Use symbol display string extension | +| `AL0036` | `AL1208` | Use null-guard helper | +| `AL0037` | `AL1209` | Use TryParse extension | +| `AL0039` | `AL1210` | Use StringComparison extension | +| `AL0040` | `AL1211` | Use attribute argument extraction extension | +| `AL0045` | `AL1212` | Use null-or-empty guard helper | +| `AL0046` | `AL1213` | Use null-or-whitespace guard helper | +| `AL0047` | `AL1214` | Use zero-guard helper | +| `AL0048` | `AL1215` | Use non-negative guard helper | +| `AL0049` | `AL1216` | Use positive-guard helper | +| `AL0050` | `AL1217` | Use empty-guid guard helper | +| `AL0051` | `AL1218` | Use defined-enum guard helper | +| `AL0125` | `AL1219` | Use *Any* string comparison extension | +| `AL0137` | `AL1220` | Use Guard.* helpers instead of throw helpers | +| `AL0057` | `AL1300` | Avoid async void methods | +| `AL0058` | `AL1301` | Avoid lock on 'this' | +| `AL0059` | `AL1302` | Avoid lock on typeof(T) | +| `AL0060` | `AL1303` | Avoid lock on string | +| `AL0104` | `AL1304` | Prefer 'await using' for IAsyncDisposable | +| `AL0105` | `AL1305` | Avoid blocking calls in async methods | +| `AL0111` | `AL1306` | Avoid SQL string interpolation in CommandText | +| `AL0112` | `AL1307` | Avoid fire-and-forget task discard | +| `AL0114` | `AL1308` | Prefer TryParse over Parse | +| `AL0115` | `AL1309` | Empty catch block swallows exceptions | +| `AL0116` | `AL1310` | Exception details leaked in HTTP response | +| `AL0117` | `AL1311` | Unnecessary LINQ materialization | +| `AL0118` | `AL1312` | Read-modify-write without transaction | +| `AL0126` | `AL1313` | Forward CancellationToken to invocations that support it | +| `AL0138` | `AL1314` | Use Math.Round/MathF.Round overload with explicit MidpointRounding | +| `AL0041` | `AL1400` | Method with [AotTest] or [TrimTest] must return int | +| `AL0042` | `AL1401` | [AotTest]/[TrimTest] method should return 100 on success | +| `AL0043` | `AL1402` | [TrimSafe] code must not call methods with [RequiresUnreferencedCode] | +| `AL0044` | `AL1403` | [AotSafe] code must not call methods with [RequiresDynamicCode] | +| `AL0052` | `AL1404` | [AotSafe] code must not call [AotUnsafe] code | +| `AL0053` | `AL1405` | Unnecessary [AotUnsafe] attribute | +| `AL0094` | `AL1406` | Avoid 'dynamic' keyword in AOT-published code | +| `AL0095` | `AL1407` | Avoid Expression.Compile() in AOT context | +| `AL0101` | `AL1408` | Activator.CreateInstance is not AOT-safe | +| `AL0102` | `AL1409` | Type.GetType with dynamic name is not AOT-safe | +| `AL0103` | `AL1500` | Closed hierarchy match is not exhaustive | +| `AL0119` | `AL1501` | Avoid storing ISymbol in source generator models | +| `AL0120` | `AL1502` | Use IIncrementalGenerator instead of ISourceGenerator | +| `AL0121` | `AL1503` | Avoid NormalizeWhitespace in source generators | +| `AL0122` | `AL1504` | [DuckDbTable] type must be partial | +| `AL0123` | `AL1505` | Conflicting [DuckDbColumn] ordinal values | +| `AL0017` | `AL1600` | Hardcoded package version detected | +| `AL0018` | `AL1601` | Version.props not imported | +| `AL0019` | `AL1602` | Undefined version variable | +| `AL0054` | `AL1603` | Diagnostic missing from documentation | +| `AL0055` | `AL1604` | Diagnostic missing from release notes | +| `AL0056` | `AL1605` | Diagnostic documentation mismatch | +| `AL0127` | `AL1606` | Outdated MAF ecosystem package version | +| `AL0025` | `AL1700` | Anonymous function can be made static | +| `AL0026` | `AL1701` | Avoid DateTime/DateTimeOffset time accessors | +| `AL0027` | `AL1702` | Avoid legacy JSON library | +| `AL0139` | `AL1703` | Use implicit type when type is apparent | +| `AL0128` | `AL1800` | Destructive Loom tool must require approval | +| `AL0129` | `AL1801` | Loom tool should declare its side effect | +| `AL0130` | `AL1802` | Loom tool should declare required capabilities | + +## Generated file + +Regenerate with: + +```bash +dotnet run --project tools/ANcpLua.Analyzers.DocsGenerator # rewrite docs + editorconfig profiles + migration-catalog +dotnet run --project tools/ANcpLua.Analyzers.DocsGenerator -- --check # CI guard; fails if anything is stale +dotnet run --project tools/ANcpLua.Analyzers.DocsGenerator -- --audit # print catalog statistics +``` diff --git a/tests/ANcpLua.Analyzers.Tests/ANcpLua.Analyzers.Tests.csproj b/tests/ANcpLua.Analyzers.Tests/ANcpLua.Analyzers.Tests.csproj index b43751a..6113710 100644 --- a/tests/ANcpLua.Analyzers.Tests/ANcpLua.Analyzers.Tests.csproj +++ b/tests/ANcpLua.Analyzers.Tests/ANcpLua.Analyzers.Tests.csproj @@ -18,5 +18,9 @@ + + \ No newline at end of file diff --git a/tests/ANcpLua.Analyzers.Tests/AnalyzerConventionTests.cs b/tests/ANcpLua.Analyzers.Tests/AnalyzerConventionTests.cs index cfa0e47..2eee2b4 100644 --- a/tests/ANcpLua.Analyzers.Tests/AnalyzerConventionTests.cs +++ b/tests/ANcpLua.Analyzers.Tests/AnalyzerConventionTests.cs @@ -1,4 +1,5 @@ using System.Reflection; +using ANcpLua.Analyzers.DocsGenerator; using Microsoft.CodeAnalysis.Diagnostics; namespace ANcpLua.Analyzers.Tests; @@ -47,6 +48,17 @@ public void AllDiagnosticIdsMatchExpectedFormat() { } } + /// + /// Highest-leverage safety net for the hand-transcribed AL0xxx → AL1xxx rename map. + /// --check on a dev machine catches OUTPUT drift but only after someone runs + /// ./build.sh CheckDocs; this test catches SOURCE drift (typo, duplicate, + /// leak into a sibling-package band) at CI on every PR. Mandatory. + /// + [Fact] + public void AlIdMigrationCatalog_StructuralInvariants_Hold() { + AlIdMigrationCatalog.Validate(); + } + [Fact] public void AllAnalyzersDeclareDiagnosticIdConsts() { const BindingFlags AnyStatic = BindingFlags.Public | BindingFlags.NonPublic diff --git a/tools/ANcpLua.Analyzers.DocsGenerator/AlIdMigrationCatalog.cs b/tools/ANcpLua.Analyzers.DocsGenerator/AlIdMigrationCatalog.cs new file mode 100644 index 0000000..ce0b136 --- /dev/null +++ b/tools/ANcpLua.Analyzers.DocsGenerator/AlIdMigrationCatalog.cs @@ -0,0 +1,166 @@ +// Copyright (c) Alexander Nachtmann +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Immutable; +using System.Text.RegularExpressions; + +namespace ANcpLua.Analyzers.DocsGenerator; + +/// +/// Hand-transcribed AL0xxx → AL1xxx rename map from the 2.0.0 break. Source of +/// truth: eng/analyzer-renumber-plan.md §2 ("Old to New ID mapping"). +/// Documentation only — this catalog is consumed by the docs generator under +/// tools/ and does NOT ship in the runtime analyzer DLL. The 2.0.1 +/// analyzer no longer emits AL0xxx, so no runtime code needs the old IDs; +/// the catalog exists purely so the generated migration-catalog markdown can +/// tell consumers "your editorconfig says AL0001 — that's now AL1000". +/// +/// If a follow-up diagnostic ever warns consumers whose editorconfig still +/// has stale dotnet_diagnostic.AL0xxx.severity entries, this catalog +/// moves to src/ANcpLua.Analyzers/ with it. +/// +/// runs structural invariant sweeps on every +/// call and is also covered by a +/// mandatory unit test in AnalyzerConventionTests — drift in the +/// hand-transcription gets caught at CI, not at "Claude ran --check on a dev +/// machine and forgot to commit the regenerated output". +/// +/// One row in the AL0xxx → AL1xxx rename map. +public sealed record AlIdRename(string OldId, string NewId, string Band, string Title); + +// Public (not internal) so the test project at tests/ANcpLua.Analyzers.Tests/ can +// invoke Validate() via a plain ProjectReference, without InternalsVisibleTo — +// IVT would expose this Exe assembly's internal top-level Program and collide +// with the tests project's own Program (CS0433). Public is safe because the +// tools assembly is never packed or referenced by consumers. +public static class AlIdMigrationCatalog +{ + public static readonly ImmutableArray Entries = + [ + new("AL0001", "AL1000", "AL1000..AL1099 Correctness", "Prohibit reassignment of primary constructor parameters"), + new("AL0002", "AL1001", "AL1000..AL1099 Correctness", "Don't repeat negated patterns"), + new("AL0003", "AL1002", "AL1000..AL1099 Correctness", "Don't divide by constant zero"), + new("AL0004", "AL1003", "AL1000..AL1099 Correctness", "Use pattern matching when comparing Span with constants"), + new("AL0005", "AL1004", "AL1000..AL1099 Correctness", "Use SequenceEqual when comparing Span with non-constants"), + new("AL0006", "AL1005", "AL1000..AL1099 Correctness", "Field name conflicts with primary constructor parameter"), + new("AL0007", "AL1006", "AL1000..AL1099 Correctness", "GetSchema should be explicitly implemented"), + new("AL0008", "AL1007", "AL1000..AL1099 Correctness", "GetSchema must return null and not be abstract"), + new("AL0009", "AL1008", "AL1000..AL1099 Correctness", "Don't call IXmlSerializable.GetSchema"), + new("AL0011", "AL1009", "AL1000..AL1099 Correctness", "Avoid lock keyword on non-Lock types"), + new("AL0014", "AL1010", "AL1000..AL1099 Correctness", "Prefer pattern matching for null and zero comparisons"), + new("AL0015", "AL1011", "AL1000..AL1099 Correctness", "Normalize null-guard style"), + new("AL0016", "AL1012", "AL1000..AL1099 Correctness", "Combine declaration with subsequent null-check"), + new("AL0020", "AL1100", "AL1100..AL1199 ASP.NET Core", "IFormCollection requires explicit attribute"), + new("AL0021", "AL1101", "AL1100..AL1199 ASP.NET Core", "Multiple structured form sources"), + new("AL0022", "AL1102", "AL1100..AL1199 ASP.NET Core", "Mixed form collection and DTO"), + new("AL0023", "AL1103", "AL1100..AL1199 ASP.NET Core", "Unsupported form type"), + new("AL0024", "AL1104", "AL1100..AL1199 ASP.NET Core", "Form and body conflict"), + new("AL0080", "AL1105", "AL1100..AL1199 ASP.NET Core", "Missing resilience configuration"), + new("AL0081", "AL1106", "AL1100..AL1199 ASP.NET Core", "Missing health checks"), + new("AL0082", "AL1107", "AL1100..AL1199 ASP.NET Core", "Consider using configuration for connection string"), + new("AL0084", "AL1108", "AL1100..AL1199 ASP.NET Core", "Missing service discovery"), + new("AL0106", "AL1109", "AL1100..AL1199 ASP.NET Core", "Avoid Task.Run in ASP.NET Core request handlers"), + new("AL0028", "AL1200", "AL1200..AL1299 Roslyn Utilities", "Use IsEqualTo extension"), + new("AL0029", "AL1201", "AL1200..AL1299 Roslyn Utilities", "Use HasAttribute extension"), + new("AL0030", "AL1202", "AL1200..AL1299 Roslyn Utilities", "Use type hierarchy extension"), + new("AL0031", "AL1203", "AL1200..AL1299 Roslyn Utilities", "Use operation extension"), + new("AL0032", "AL1204", "AL1200..AL1299 Roslyn Utilities", "Use OrEmpty extension"), + new("AL0033", "AL1205", "AL1200..AL1299 Roslyn Utilities", "Use ToImmutableArrayOrEmpty extension"), + new("AL0034", "AL1206", "AL1200..AL1299 Roslyn Utilities", "Use WhereNotNull extension"), + new("AL0035", "AL1207", "AL1200..AL1299 Roslyn Utilities", "Use symbol display string extension"), + new("AL0036", "AL1208", "AL1200..AL1299 Roslyn Utilities", "Use null-guard helper"), + new("AL0037", "AL1209", "AL1200..AL1299 Roslyn Utilities", "Use TryParse extension"), + new("AL0039", "AL1210", "AL1200..AL1299 Roslyn Utilities", "Use StringComparison extension"), + new("AL0040", "AL1211", "AL1200..AL1299 Roslyn Utilities", "Use attribute argument extraction extension"), + new("AL0045", "AL1212", "AL1200..AL1299 Roslyn Utilities", "Use null-or-empty guard helper"), + new("AL0046", "AL1213", "AL1200..AL1299 Roslyn Utilities", "Use null-or-whitespace guard helper"), + new("AL0047", "AL1214", "AL1200..AL1299 Roslyn Utilities", "Use zero-guard helper"), + new("AL0048", "AL1215", "AL1200..AL1299 Roslyn Utilities", "Use non-negative guard helper"), + new("AL0049", "AL1216", "AL1200..AL1299 Roslyn Utilities", "Use positive-guard helper"), + new("AL0050", "AL1217", "AL1200..AL1299 Roslyn Utilities", "Use empty-guid guard helper"), + new("AL0051", "AL1218", "AL1200..AL1299 Roslyn Utilities", "Use defined-enum guard helper"), + new("AL0125", "AL1219", "AL1200..AL1299 Roslyn Utilities", "Use *Any* string comparison extension"), + new("AL0137", "AL1220", "AL1200..AL1299 Roslyn Utilities", "Use Guard.* helpers instead of throw helpers"), + new("AL0057", "AL1300", "AL1300..AL1399 Async / reliability", "Avoid async void methods"), + new("AL0058", "AL1301", "AL1300..AL1399 Async / reliability", "Avoid lock on 'this'"), + new("AL0059", "AL1302", "AL1300..AL1399 Async / reliability", "Avoid lock on typeof(T)"), + new("AL0060", "AL1303", "AL1300..AL1399 Async / reliability", "Avoid lock on string"), + new("AL0104", "AL1304", "AL1300..AL1399 Async / reliability", "Prefer 'await using' for IAsyncDisposable"), + new("AL0105", "AL1305", "AL1300..AL1399 Async / reliability", "Avoid blocking calls in async methods"), + new("AL0111", "AL1306", "AL1300..AL1399 Async / reliability", "Avoid SQL string interpolation in CommandText"), + new("AL0112", "AL1307", "AL1300..AL1399 Async / reliability", "Avoid fire-and-forget task discard"), + new("AL0114", "AL1308", "AL1300..AL1399 Async / reliability", "Prefer TryParse over Parse"), + new("AL0115", "AL1309", "AL1300..AL1399 Async / reliability", "Empty catch block swallows exceptions"), + new("AL0116", "AL1310", "AL1300..AL1399 Async / reliability", "Exception details leaked in HTTP response"), + new("AL0117", "AL1311", "AL1300..AL1399 Async / reliability", "Unnecessary LINQ materialization"), + new("AL0118", "AL1312", "AL1300..AL1399 Async / reliability", "Read-modify-write without transaction"), + new("AL0126", "AL1313", "AL1300..AL1399 Async / reliability", "Forward CancellationToken to invocations that support it"), + new("AL0138", "AL1314", "AL1300..AL1399 Async / reliability", "Use Math.Round/MathF.Round overload with explicit MidpointRounding"), + new("AL0041", "AL1400", "AL1400..AL1499 AOT / trim", "Method with [AotTest] or [TrimTest] must return int"), + new("AL0042", "AL1401", "AL1400..AL1499 AOT / trim", "[AotTest]/[TrimTest] method should return 100 on success"), + new("AL0043", "AL1402", "AL1400..AL1499 AOT / trim", "[TrimSafe] code must not call methods with [RequiresUnreferencedCode]"), + new("AL0044", "AL1403", "AL1400..AL1499 AOT / trim", "[AotSafe] code must not call methods with [RequiresDynamicCode]"), + new("AL0052", "AL1404", "AL1400..AL1499 AOT / trim", "[AotSafe] code must not call [AotUnsafe] code"), + new("AL0053", "AL1405", "AL1400..AL1499 AOT / trim", "Unnecessary [AotUnsafe] attribute"), + new("AL0094", "AL1406", "AL1400..AL1499 AOT / trim", "Avoid 'dynamic' keyword in AOT-published code"), + new("AL0095", "AL1407", "AL1400..AL1499 AOT / trim", "Avoid Expression.Compile() in AOT context"), + new("AL0101", "AL1408", "AL1400..AL1499 AOT / trim", "Activator.CreateInstance is not AOT-safe"), + new("AL0102", "AL1409", "AL1400..AL1499 AOT / trim", "Type.GetType with dynamic name is not AOT-safe"), + new("AL0103", "AL1500", "AL1500..AL1599 Roslyn-author hygiene", "Closed hierarchy match is not exhaustive"), + new("AL0119", "AL1501", "AL1500..AL1599 Roslyn-author hygiene", "Avoid storing ISymbol in source generator models"), + new("AL0120", "AL1502", "AL1500..AL1599 Roslyn-author hygiene", "Use IIncrementalGenerator instead of ISourceGenerator"), + new("AL0121", "AL1503", "AL1500..AL1599 Roslyn-author hygiene", "Avoid NormalizeWhitespace in source generators"), + new("AL0122", "AL1504", "AL1500..AL1599 Roslyn-author hygiene", "[DuckDbTable] type must be partial"), + new("AL0123", "AL1505", "AL1500..AL1599 Roslyn-author hygiene", "Conflicting [DuckDbColumn] ordinal values"), + new("AL0017", "AL1600", "AL1600..AL1699 Package / version", "Hardcoded package version detected"), + new("AL0018", "AL1601", "AL1600..AL1699 Package / version", "Version.props not imported"), + new("AL0019", "AL1602", "AL1600..AL1699 Package / version", "Undefined version variable"), + new("AL0054", "AL1603", "AL1600..AL1699 Package / version", "Diagnostic missing from documentation"), + new("AL0055", "AL1604", "AL1600..AL1699 Package / version", "Diagnostic missing from release notes"), + new("AL0056", "AL1605", "AL1600..AL1699 Package / version", "Diagnostic documentation mismatch"), + new("AL0127", "AL1606", "AL1600..AL1699 Package / version", "Outdated MAF ecosystem package version"), + new("AL0025", "AL1700", "AL1700..AL1799 Style", "Anonymous function can be made static"), + new("AL0026", "AL1701", "AL1700..AL1799 Style", "Avoid DateTime/DateTimeOffset time accessors"), + new("AL0027", "AL1702", "AL1700..AL1799 Style", "Avoid legacy JSON library"), + new("AL0139", "AL1703", "AL1700..AL1799 Style", "Use implicit type when type is apparent"), + new("AL0128", "AL1800", "AL1800..AL1899 Agent governance", "Destructive Loom tool must require approval"), + new("AL0129", "AL1801", "AL1800..AL1899 Agent governance", "Loom tool should declare its side effect"), + new("AL0130", "AL1802", "AL1800..AL1899 Agent governance", "Loom tool should declare required capabilities"), + ]; + + private static readonly Regex NewIdRegex = new(@"^AL1[0-8]\d{2}$", RegexOptions.Compiled); + private static readonly Regex OldIdRegex = new(@"^AL\d{4}$", RegexOptions.Compiled); + + /// + /// Structural invariants. No hardcoded ExpectedCount — count is a consequence + /// of the actual safety properties below, not a property worth asserting. + /// + public static void Validate() + { + if (Entries.IsDefaultOrEmpty) + throw new InvalidOperationException("AlIdMigrationCatalog.Entries must be populated."); + + var dupOld = Entries.GroupBy(e => e.OldId, StringComparer.Ordinal) + .FirstOrDefault(g => g.Count() > 1); + if (dupOld is not null) + throw new InvalidOperationException($"Duplicate OldId in catalog: {dupOld.Key}"); + + var dupNew = Entries.GroupBy(e => e.NewId, StringComparer.Ordinal) + .FirstOrDefault(g => g.Count() > 1); + if (dupNew is not null) + throw new InvalidOperationException($"Duplicate NewId in catalog: {dupNew.Key}"); + + // Post-renumber band: AL1000..AL1899 (9 bands of 100, per renumber-plan §1). + // Sibling packages (AotReflection, ExtensibleEnumMirror, DiscriminatedUnion) + // own slots inside AL0xxx — a leak into NewId means the renumber regressed. + var badNew = Entries.FirstOrDefault(e => !NewIdRegex.IsMatch(e.NewId)); + if (badNew is not null) + throw new InvalidOperationException( + $"NewId {badNew.NewId} (was {badNew.OldId}) violates ^AL1[0-8]\\d{{2}}$."); + + var badOld = Entries.FirstOrDefault(e => !OldIdRegex.IsMatch(e.OldId)); + if (badOld is not null) + throw new InvalidOperationException( + $"OldId {badOld.OldId} violates ^AL\\d{{4}}$."); + } +} diff --git a/tools/ANcpLua.Analyzers.DocsGenerator/DocsGenerator.cs b/tools/ANcpLua.Analyzers.DocsGenerator/DocsGenerator.cs index 689b98a..132971b 100644 --- a/tools/ANcpLua.Analyzers.DocsGenerator/DocsGenerator.cs +++ b/tools/ANcpLua.Analyzers.DocsGenerator/DocsGenerator.cs @@ -5,14 +5,14 @@ namespace ANcpLua.Analyzers.DocsGenerator; /// /// Top-level orchestrator. Owns the mode dispatch (), -/// the four generated-artifact pipelines (Generate + Check), and the +/// the generated-artifact pipelines (Generate + Check), and the /// source-side EnforceIds rewriter. Every other class in this project is /// pure logic invoked from here. /// -/// Extension point: each generated artifact (index, per-rule pages, SARIF, -/// editorconfig) is one numbered step in + . -/// Adding a new artifact means adding a focused renderer class and one numbered step -/// in both methods. +/// Extension point: each generated artifact (index, per-rule pages, migration +/// catalog, SARIF, editorconfig) is one numbered step in + +/// . Adding a new artifact means adding a focused renderer class +/// and one numbered step in both methods. /// internal static class DocsGenerator { @@ -27,28 +27,35 @@ public static int Run(string[] args) var descriptors = DescriptorCatalog.GetDescriptors(); var fixableIds = DescriptorCatalog.GetFixableDiagnosticIds(); + var migrationStats = MigrationCatalogStats.Compute(); return mode switch { - Mode.Audit => Audit(descriptors, fixableIds), - Mode.Check => Check(descriptors, fixableIds, outputPath, repoRoot), - _ => Generate(descriptors, fixableIds, outputPath, repoRoot), + Mode.Audit => Audit(descriptors, fixableIds, migrationStats), + Mode.Check => Check(descriptors, fixableIds, migrationStats, outputPath, repoRoot), + _ => Generate(descriptors, fixableIds, migrationStats, outputPath, repoRoot), }; } - private static int Audit(IReadOnlyList descriptors, HashSet fixableIds) + private static int Audit( + IReadOnlyList descriptors, + HashSet fixableIds, + MigrationCatalogStats migrationStats) { Console.WriteLine($"{RepoLayout.PackageName} catalog audit"); Console.WriteLine($" Total descriptors: {descriptors.Count}"); Console.WriteLine($" With code fix: {descriptors.Count(d => fixableIds.Contains(d.Id))}"); foreach (var g in descriptors.GroupBy(d => d.DefaultSeverity).OrderByDescending(g => g.Key)) Console.WriteLine($" Severity {g.Key,-10} {g.Count()}"); + Console.WriteLine(); + Console.Write(migrationStats.RenderAudit()); return 0; } private static int Check( IReadOnlyList descriptors, HashSet fixableIds, + MigrationCatalogStats migrationStats, string outputPath, string repoRoot) { @@ -113,7 +120,20 @@ private static int Check( } } - // (3) Editorconfig profiles. + // (3) Migration catalog (AL0xxx → AL1xxx rename map from the 2.0.0 break). + var migrationPath = RepoLayout.MigrationCatalogPath(repoRoot); + if (!File.Exists(migrationPath)) + { + Console.Error.WriteLine($"Missing migration catalog: {Path.GetRelativePath(repoRoot, migrationPath)}"); + return 1; + } + if (!string.Equals(File.ReadAllText(migrationPath), MigrationCatalogRenderer.Render(migrationStats), StringComparison.Ordinal)) + { + Console.Error.WriteLine($"Migration catalog is stale: {Path.GetRelativePath(repoRoot, migrationPath)}"); + return 1; + } + + // (4) Editorconfig profiles. foreach (var (path, expected) in EditorconfigRenderer.EnumerateProfiles(repoRoot, descriptors)) { if (!File.Exists(path)) @@ -128,7 +148,7 @@ private static int Check( } } - // (4) SARIF v2.1.0 rule manifest for tool interop (Sonar bridges, GitHub + // (5) SARIF v2.1.0 rule manifest for tool interop (Sonar bridges, GitHub // Advanced Security uploads, IDE rule catalogs). var sarifPath = RepoLayout.SarifPath(repoRoot); if (!File.Exists(sarifPath)) @@ -144,6 +164,7 @@ private static int Check( Console.WriteLine($"Index docs are up to date: {Path.GetRelativePath(repoRoot, outputPath)}"); Console.WriteLine($"Per-rule pages are up to date ({descriptors.Count})."); + Console.WriteLine($"Migration catalog is up to date: {Path.GetRelativePath(repoRoot, migrationPath)}"); Console.WriteLine("Editorconfig profiles are up to date."); Console.WriteLine($"SARIF manifest is up to date: {Path.GetRelativePath(repoRoot, sarifPath)}"); Console.WriteLine("HelpLinkUri values match per-rule page URLs."); @@ -153,6 +174,7 @@ private static int Check( private static int Generate( IReadOnlyList descriptors, HashSet fixableIds, + MigrationCatalogStats migrationStats, string outputPath, string repoRoot) { @@ -186,12 +208,18 @@ private static int Generate( } Console.WriteLine($"Wrote {descriptors.Count} per-rule pages under docs/rules/"); - // (3) SARIF v2.1.0 rule manifest. + // (3) Migration catalog (AL0xxx → AL1xxx rename map). + var migrationPath = RepoLayout.MigrationCatalogPath(repoRoot); + Directory.CreateDirectory(Path.GetDirectoryName(migrationPath)!); + File.WriteAllText(migrationPath, MigrationCatalogRenderer.Render(migrationStats)); + Console.WriteLine($"Wrote {Path.GetRelativePath(repoRoot, migrationPath)}"); + + // (4) SARIF v2.1.0 rule manifest. var sarifPath = RepoLayout.SarifPath(repoRoot); File.WriteAllText(sarifPath, SarifRenderer.Render(descriptors, idToClass)); Console.WriteLine($"Wrote {Path.GetRelativePath(repoRoot, sarifPath)}"); - // (4) Editorconfig profiles. + // (5) Editorconfig profiles. foreach (var (path, content) in EditorconfigRenderer.EnumerateProfiles(repoRoot, descriptors)) { Directory.CreateDirectory(Path.GetDirectoryName(path)!); diff --git a/tools/ANcpLua.Analyzers.DocsGenerator/EnforceIdsRewriter.cs b/tools/ANcpLua.Analyzers.DocsGenerator/EnforceIdsRewriter.cs index 7c22e44..4155f50 100644 --- a/tools/ANcpLua.Analyzers.DocsGenerator/EnforceIdsRewriter.cs +++ b/tools/ANcpLua.Analyzers.DocsGenerator/EnforceIdsRewriter.cs @@ -68,7 +68,7 @@ public static int Run(string repoRoot, bool apply) classRenames[className] = expectedClassName; } - // (2) Class XML doc summary: rewrite "/// AL00XX:" tokens only when they + // (2) Class XML doc summary: rewrite "/// AL####:" tokens only when they // don't appear in this class's SupportedDiagnostics set. Multi-diagnostic // analyzers (e.g., AL1003ToAL1004 documents both IDs in the summary) are // not "wrong" — each row is the doc for one of the registered descriptors. diff --git a/tools/ANcpLua.Analyzers.DocsGenerator/IndexDocsRenderer.cs b/tools/ANcpLua.Analyzers.DocsGenerator/IndexDocsRenderer.cs index e37ae66..5bb4489 100644 --- a/tools/ANcpLua.Analyzers.DocsGenerator/IndexDocsRenderer.cs +++ b/tools/ANcpLua.Analyzers.DocsGenerator/IndexDocsRenderer.cs @@ -41,7 +41,8 @@ private static void WriteHeader(StringBuilder sb) sb.AppendLine(); sb.AppendLine("## Package family"); sb.AppendLine(); - sb.AppendLine("- **[ANcpLua.Analyzers](https://github.com/ANcpLua/ANcpLua.Analyzers)** — this package; the `AL00xx`–`AL18xx` Roslyn diagnostics."); + // renumber: post-2.0.0, AL ids live in AL10xx–AL18xx (sibling packages own AL0xxx). + sb.AppendLine("- **[ANcpLua.Analyzers](https://github.com/ANcpLua/ANcpLua.Analyzers)** — this package; the `AL10xx`–`AL18xx` Roslyn diagnostics."); sb.AppendLine("- **[ANcpLua.NET.Sdk](https://github.com/ANcpLua/ANcpLua.NET.Sdk)** — MSBuild SDK that auto-injects this analyzer + the bundled `editorconfig` severity profile."); sb.AppendLine("- **[ANcpLua.Roslyn.Utilities](https://github.com/ANcpLua/ANcpLua.Roslyn.Utilities)** — shared Roslyn helpers + the `Guard.*` API the `AL12xx` band promotes."); } @@ -72,7 +73,8 @@ private static void WriteRelatedDocs(StringBuilder sb) { sb.AppendLine("## Consumer-side severity profile (`AlAnalysisMode`)"); sb.AppendLine(); - sb.AppendLine("Set `` in your csproj to switch the whole `AL00xx`–`AL18xx` band in one line instead of dropping editorconfig files:"); + // renumber: post-2.0.0 band; bump if the AL bands shift again. + sb.AppendLine("Set `` in your csproj to switch the whole `AL10xx`–`AL18xx` band in one line instead of dropping editorconfig files:"); sb.AppendLine(); sb.AppendLine("```xml"); sb.AppendLine(""); @@ -91,9 +93,11 @@ private static void WriteRelatedDocs(StringBuilder sb) sb.AppendLine(); sb.AppendLine("## See also"); sb.AppendLine(); - sb.AppendLine("- [Per-rule pages](rules/) — one markdown file per `AL00xx`–`AL18xx` rule with severity, category, code-fix status, and description."); + // renumber: post-2.0.0 band references; bump if the AL bands shift again. + sb.AppendLine("- [Per-rule pages](rules/) — one markdown file per `AL10xx`–`AL18xx` rule with severity, category, code-fix status, and description."); sb.AppendLine("- [Editorconfig profiles](editorconfig/) — three drop-in severity profiles: `Default`, `AllRulesAsErrors`, `AllRulesDisabled`. Same content ships inside the NuGet under `buildTransitive/editorconfig/`."); - sb.AppendLine($"- [SARIF rule manifest]({RepoLayout.PackageName}.sarif) — SARIF v2.1.0 catalog of every `AL00xx`–`AL18xx` rule (id, name, severity, category, helpUri). Consume from Sonar bridges, GitHub Advanced Security uploads, or IDE rule-catalog tools."); + sb.AppendLine($"- [SARIF rule manifest]({RepoLayout.PackageName}.sarif) — SARIF v2.1.0 catalog of every `AL10xx`–`AL18xx` rule (id, name, severity, category, helpUri). Consume from Sonar bridges, GitHub Advanced Security uploads, or IDE rule-catalog tools."); + sb.AppendLine("- [Migration catalog](migration-catalog.md) — the `AL0xxx` → `AL1xxx` rename map from the 2.0.0 break. Use to resolve stale `dotnet_diagnostic.AL0xxx.severity = …` editorconfig entries."); sb.AppendLine("- [`AnalyzerReleases.Unshipped.md`](../src/ANcpLua.Analyzers/AnalyzerReleases.Unshipped.md) — release-tracking manifest with `ClassName` attribution per Microsoft NetAnalyzers convention."); } diff --git a/tools/ANcpLua.Analyzers.DocsGenerator/MigrationCatalogRenderer.cs b/tools/ANcpLua.Analyzers.DocsGenerator/MigrationCatalogRenderer.cs new file mode 100644 index 0000000..e9b01cf --- /dev/null +++ b/tools/ANcpLua.Analyzers.DocsGenerator/MigrationCatalogRenderer.cs @@ -0,0 +1,143 @@ +// Copyright (c) Alexander Nachtmann +// SPDX-License-Identifier: Apache-2.0 + +using System.Text; + +namespace ANcpLua.Analyzers.DocsGenerator; + +/// +/// Renders docs/migration-catalog.md: the AL0xxx → AL1xxx rename map +/// from the 2.0.0 break. Consumer-facing answer to the question +/// "I have dotnet_diagnostic.AL0001.severity = error in my +/// editorconfig — what's it called now?". +/// +/// Extension point: every section is a void(StringBuilder, MigrationCatalogStats) +/// in the sections array. New views (per-band severity heat-map, codefix +/// coverage column, etc.) plug in by appending to that array. +/// +internal static class MigrationCatalogRenderer +{ + public static string Render(MigrationCatalogStats stats) + { + var sb = new StringBuilder(); + var sections = new Action[] + { + (b, _) => WriteHeader(b), + WriteSummary, + WriteCompletionAudit, + WriteBandBreakdown, + WriteRenameTable, + (b, _) => WriteGeneratedFile(b), + }; + + foreach (var section in sections) + { + if (sb.Length > 0) sb.AppendLine(); + section(sb, stats); + } + + return sb.ToString().ReplaceLineEndings("\n"); + } + + private static void WriteHeader(StringBuilder sb) + { + sb.AppendLine("# AL Analyzer Renumber Catalog"); + sb.AppendLine(); + sb.AppendLine($""); + sb.AppendLine(); + sb.AppendLine($"AL0xxx → AL1xxx rename map from the {RepoLayout.PackageName} 2.0.0 break. The 2.0.1 analyzer no longer emits any `AL0xxx` ID, so consumers carrying `dotnet_diagnostic.AL0xxx.severity = …` entries from an older version need this table to find the replacement."); + } + + private static void WriteSummary(StringBuilder sb, MigrationCatalogStats stats) + { + sb.AppendLine("## Summary"); + sb.AppendLine(); + sb.AppendLine($"`{stats.RenameCount}` rename{(stats.RenameCount == 1 ? "" : "s")} across `{stats.BandCount}` 100-wide domain band{(stats.BandCount == 1 ? "" : "s")}. The `AL0xxx` range is reserved for sibling packages (`ANcpLua.AotReflection`, `ANcpLua.ExtensibleEnumMirror`, `ANcpLua.DiscriminatedUnion`) and is not reissued by this analyzer."); + } + + private static void WriteCompletionAudit(StringBuilder sb, MigrationCatalogStats _) + { + sb.AppendLine("## Completion audit"); + sb.AppendLine(); + sb.AppendLine("`AlIdMigrationCatalog.Validate()` enforces these invariants on every generator run + on every CI build (via `AnalyzerConventionTests.AlIdMigrationCatalog_StructuralInvariants_Hold`):"); + sb.AppendLine(); + sb.AppendLine("- No duplicate `OldId` across the catalog."); + sb.AppendLine("- No duplicate `NewId` across the catalog."); + sb.AppendLine("- Every `NewId` matches `^AL1[0-8]\\d{2}$` — sits inside the post-renumber band `AL1000..AL1899`."); + sb.AppendLine("- Every `OldId` matches `^AL\\d{4}$` — well-formed legacy diagnostic ID."); + sb.AppendLine(); + sb.AppendLine("No hardcoded `ExpectedCount` constant — count is a consequence of the invariants, not a property worth asserting on its own."); + } + + private static void WriteBandBreakdown(StringBuilder sb, MigrationCatalogStats stats) + { + sb.AppendLine("## Renames by band"); + sb.AppendLine(); + sb.AppendLine("| Band | Count |"); + sb.AppendLine("| -- | --: |"); + foreach (var g in stats.Bands) + sb.AppendLine($"| {g.Band} | {g.Count} |"); + } + + private static void WriteRenameTable(StringBuilder sb, MigrationCatalogStats _) + { + sb.AppendLine("## Old → New mapping"); + sb.AppendLine(); + sb.AppendLine("Sorted by `NewId` — scan to find your current ID's predecessor."); + sb.AppendLine(); + sb.AppendLine("| Old ID | New ID | Title |"); + sb.AppendLine("| -- | -- | -- |"); + foreach (var e in AlIdMigrationCatalog.Entries.OrderBy(x => x.NewId, StringComparer.Ordinal)) + sb.AppendLine($"| `{e.OldId}` | `{e.NewId}` | {MarkdownFormatting.Escape(e.Title)} |"); + } + + private static void WriteGeneratedFile(StringBuilder sb) + { + sb.AppendLine("## Generated file"); + sb.AppendLine(); + sb.AppendLine("Regenerate with:"); + sb.AppendLine(); + sb.AppendLine("```bash"); + sb.AppendLine($"dotnet run --project {RepoLayout.ProjectRelativePath} # rewrite docs + editorconfig profiles + migration-catalog"); + sb.AppendLine($"dotnet run --project {RepoLayout.ProjectRelativePath} -- --check # CI guard; fails if anything is stale"); + sb.AppendLine($"dotnet run --project {RepoLayout.ProjectRelativePath} -- --audit # print catalog statistics"); + sb.AppendLine("```"); + } +} + +/// +/// Computed view of . also +/// calls so any contract drift fails +/// before we start writing files. Mirrors the QYL CatalogStatistics shape +/// for cross-repo familiarity. +/// +internal sealed record MigrationCatalogStats( + int RenameCount, + int BandCount, + IReadOnlyList<(string Band, int Count)> Bands) +{ + public static MigrationCatalogStats Compute() + { + AlIdMigrationCatalog.Validate(); + var bands = AlIdMigrationCatalog.Entries + .GroupBy(e => e.Band, StringComparer.Ordinal) + .OrderBy(g => g.Key, StringComparer.Ordinal) + .Select(g => (Band: g.Key, Count: g.Count())) + .ToArray(); + return new MigrationCatalogStats( + RenameCount: AlIdMigrationCatalog.Entries.Length, + BandCount: bands.Length, + Bands: bands); + } + + public string RenderAudit() + { + var sb = new StringBuilder(); + sb.AppendLine($"AL ID migration catalog audit"); + sb.AppendLine($" Total renames: {RenameCount}"); + sb.AppendLine($" Domain bands: {BandCount}"); + foreach (var (band, count) in Bands) + sb.AppendLine($" {band,-40} {count}"); + return sb.ToString(); + } +} diff --git a/tools/ANcpLua.Analyzers.DocsGenerator/RepoLayout.cs b/tools/ANcpLua.Analyzers.DocsGenerator/RepoLayout.cs index c6710b0..561c5b9 100644 --- a/tools/ANcpLua.Analyzers.DocsGenerator/RepoLayout.cs +++ b/tools/ANcpLua.Analyzers.DocsGenerator/RepoLayout.cs @@ -33,6 +33,11 @@ public static string SarifPath(string repoRoot) => public static string EditorconfigDir(string repoRoot) => Path.Combine(repoRoot, "docs", "editorconfig"); + // Migration catalog (AL0xxx → AL1xxx rename map from the 2.0.0 break). + // Content source: AlIdMigrationCatalog.Entries. See MigrationCatalogRenderer. + public static string MigrationCatalogPath(string repoRoot) => + Path.Combine(repoRoot, "docs", "migration-catalog.md"); + public static string AnalyzersSourceDir(string repoRoot) => Path.Combine(repoRoot, "src", PackageName, "Analyzers");