From 9921cfdbe718dae64e5ecb71ffdede041eb4eaea Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sun, 27 Sep 2026 06:56:19 +0200 Subject: [PATCH 1/4] test(LC0091): add obsolete-state fixtures mirroring compiler XLIFF locking Co-Authored-By: Claude Fable 5.1 --- .../GlobalLabelInObsoleteRemovedTable.al | 17 +++++++++++++ .../LocalLabelInObsoletePendingCodeunit.al | 11 ++++++++ .../ObsoletePendingPageControlToolTip.al | 25 +++++++++++++++++++ .../ObsoletePendingTableCaption.al | 14 +++++++++++ .../ObsoletePendingTableFieldCaption.al | 14 +++++++++++ .../ReportLabelInObsoletePendingReport.al | 10 ++++++++ .../LockedLabelInObsoleteRemovedTable.al | 13 ++++++++++ .../ObsoleteRemovedTableCaption.al | 14 +++++++++++ .../ObsoleteRemovedTableFieldCaption.al | 13 ++++++++++ ...bleExtensionFieldOnObsoleteRemovedTable.al | 21 ++++++++++++++++ .../TranslatableTextShouldBeTranslated.cs | 10 ++++++++ 11 files changed, 162 insertions(+) create mode 100644 src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/GlobalLabelInObsoleteRemovedTable.al create mode 100644 src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/LocalLabelInObsoletePendingCodeunit.al create mode 100644 src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/ObsoletePendingPageControlToolTip.al create mode 100644 src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/ObsoletePendingTableCaption.al create mode 100644 src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/ObsoletePendingTableFieldCaption.al create mode 100644 src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/ReportLabelInObsoletePendingReport.al create mode 100644 src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/NoDiagnostic/LockedLabelInObsoleteRemovedTable.al create mode 100644 src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/NoDiagnostic/ObsoleteRemovedTableCaption.al create mode 100644 src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/NoDiagnostic/ObsoleteRemovedTableFieldCaption.al create mode 100644 src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/NoDiagnostic/TableExtensionFieldOnObsoleteRemovedTable.al diff --git a/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/GlobalLabelInObsoleteRemovedTable.al b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/GlobalLabelInObsoleteRemovedTable.al new file mode 100644 index 00000000..10ad1a42 --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/GlobalLabelInObsoleteRemovedTable.al @@ -0,0 +1,17 @@ +table 50100 MyTable +{ + Caption = 'My Table'; + ObsoleteState = Removed; + ObsoleteReason = 'Replaced by MyNewTable.'; + + fields + { + field(1; MyField; Text[100]) + { + Caption = 'My Field'; + } + } + + var + [|MyGlobalLabel: Label 'Hello World'|]; +} diff --git a/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/LocalLabelInObsoletePendingCodeunit.al b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/LocalLabelInObsoletePendingCodeunit.al new file mode 100644 index 00000000..31c7e6ae --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/LocalLabelInObsoletePendingCodeunit.al @@ -0,0 +1,11 @@ +codeunit 50100 MyCodeunit +{ + ObsoleteState = Pending; + ObsoleteReason = 'Replaced by MyNewCodeunit.'; + + procedure MyProcedure() + var + [|MyLabel: Label 'Hello World'|]; + begin + end; +} diff --git a/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/ObsoletePendingPageControlToolTip.al b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/ObsoletePendingPageControlToolTip.al new file mode 100644 index 00000000..738bd29b --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/ObsoletePendingPageControlToolTip.al @@ -0,0 +1,25 @@ +page 50100 MyPage +{ + SourceTable = MyTable; + + layout + { + area(Content) + { + field(MyField; Rec.MyField) + { + [|ToolTip = 'This is a tooltip'|]; + ObsoleteState = Pending; + ObsoleteReason = 'Replaced by MyNewField.'; + } + } + } +} + +table 50100 MyTable +{ + fields + { + field(1; MyField; Text[100]) { } + } +} diff --git a/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/ObsoletePendingTableCaption.al b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/ObsoletePendingTableCaption.al new file mode 100644 index 00000000..7d6f4d73 --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/ObsoletePendingTableCaption.al @@ -0,0 +1,14 @@ +table 50100 MyTable +{ + [|Caption = 'My Table'|]; + ObsoleteState = Pending; + ObsoleteReason = 'Replaced by MyNewTable.'; + + fields + { + field(1; MyField; Text[100]) + { + [|Caption = 'My Field'|]; + } + } +} diff --git a/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/ObsoletePendingTableFieldCaption.al b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/ObsoletePendingTableFieldCaption.al new file mode 100644 index 00000000..9544d429 --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/ObsoletePendingTableFieldCaption.al @@ -0,0 +1,14 @@ +table 50100 MyTable +{ + fields + { + field(1; "No."; Code[20]) { } + field(2; MyField; Text[100]) + { + [|Caption = 'My Field'|]; + [|ToolTip = 'Specifies my field.'|]; + ObsoleteState = Pending; + ObsoleteReason = 'Replaced by MyNewField.'; + } + } +} diff --git a/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/ReportLabelInObsoletePendingReport.al b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/ReportLabelInObsoletePendingReport.al new file mode 100644 index 00000000..78f51544 --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/HasDiagnostic/ReportLabelInObsoletePendingReport.al @@ -0,0 +1,10 @@ +report 50100 MyReport +{ + ObsoleteState = Pending; + ObsoleteReason = 'Replaced by MyNewReport.'; + + labels + { + [|MyReportLabel = 'Report Label Text'|]; + } +} diff --git a/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/NoDiagnostic/LockedLabelInObsoleteRemovedTable.al b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/NoDiagnostic/LockedLabelInObsoleteRemovedTable.al new file mode 100644 index 00000000..e317c3c2 --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/NoDiagnostic/LockedLabelInObsoleteRemovedTable.al @@ -0,0 +1,13 @@ +table 50100 MyTable +{ + ObsoleteState = Removed; + ObsoleteReason = 'Replaced by MyNewTable.'; + + fields + { + field(1; MyField; Text[100]) { } + } + + var + [|MyGlobalLabel: Label 'Hello World', Locked = true|]; +} diff --git a/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/NoDiagnostic/ObsoleteRemovedTableCaption.al b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/NoDiagnostic/ObsoleteRemovedTableCaption.al new file mode 100644 index 00000000..e2f124db --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/NoDiagnostic/ObsoleteRemovedTableCaption.al @@ -0,0 +1,14 @@ +table 50100 MyTable +{ + [|Caption = 'My Table'|]; + ObsoleteState = Removed; + ObsoleteReason = 'Replaced by MyNewTable.'; + + fields + { + field(1; MyField; Text[100]) + { + [|Caption = 'My Field'|]; + } + } +} diff --git a/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/NoDiagnostic/ObsoleteRemovedTableFieldCaption.al b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/NoDiagnostic/ObsoleteRemovedTableFieldCaption.al new file mode 100644 index 00000000..f7d142e6 --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/NoDiagnostic/ObsoleteRemovedTableFieldCaption.al @@ -0,0 +1,13 @@ +table 50100 MyTable +{ + fields + { + field(1; "No."; Code[20]) { } + field(2; MyField; Text[100]) + { + [|Caption = 'My Field'|]; + ObsoleteState = Removed; + ObsoleteReason = 'Replaced by MyNewField.'; + } + } +} diff --git a/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/NoDiagnostic/TableExtensionFieldOnObsoleteRemovedTable.al b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/NoDiagnostic/TableExtensionFieldOnObsoleteRemovedTable.al new file mode 100644 index 00000000..8a7f2ab4 --- /dev/null +++ b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/NoDiagnostic/TableExtensionFieldOnObsoleteRemovedTable.al @@ -0,0 +1,21 @@ +table 50100 MyTable +{ + ObsoleteState = Removed; + ObsoleteReason = 'Replaced by MyNewTable.'; + + fields + { + field(1; MyField; Text[100]) { } + } +} + +tableextension 50100 MyTableExt extends MyTable +{ + fields + { + field(50100; MyExtField; Text[100]) + { + [|Caption = 'My Extension Field'|]; + } + } +} diff --git a/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/TranslatableTextShouldBeTranslated.cs b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/TranslatableTextShouldBeTranslated.cs index 0a5c5ab0..bee690e7 100644 --- a/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/TranslatableTextShouldBeTranslated.cs +++ b/src/ALCops.LinterCop.Test/Rules/TranslatableTextShouldBeTranslated/TranslatableTextShouldBeTranslated.cs @@ -320,6 +320,12 @@ private static AnalyzerTestFixture CreateFixtureWithLegacyRuntime(byte[] xliffCo [TestCase("PageControlToolTip")] [TestCase("PageAnalysisViewCaption")] [TestCase("ReportLabel")] + [TestCase("ObsoletePendingTableCaption")] + [TestCase("ObsoletePendingTableFieldCaption")] + [TestCase("ObsoletePendingPageControlToolTip")] + [TestCase("GlobalLabelInObsoleteRemovedTable")] + [TestCase("LocalLabelInObsoletePendingCodeunit")] + [TestCase("ReportLabelInObsoletePendingReport")] public async Task HasDiagnostic(string testCase) { RequireMinimumVersion("16.0", @@ -344,6 +350,10 @@ public async Task HasDiagnostic(string testCase) [TestCase("LockedLabel")] [TestCase("LockedReportLabel")] [TestCase("PageAnalysisViewLockedCaption")] + [TestCase("ObsoleteRemovedTableCaption")] + [TestCase("ObsoleteRemovedTableFieldCaption")] + [TestCase("LockedLabelInObsoleteRemovedTable")] + [TestCase("TableExtensionFieldOnObsoleteRemovedTable")] public async Task NoDiagnostic(string testCase) { RequireMinimumVersion("16.0", From 402d7ee538d49afad728aaa97b8071618dff48de Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sun, 27 Sep 2026 06:57:37 +0200 Subject: [PATCH 2/4] fix(LC0091): mirror compiler XLIFF locking for obsolete symbols Co-Authored-By: Claude Fable 5.1 --- .../Extensions/SymbolInterfaceExtensions.cs | 6 ++++ .../TranslatableTextShouldBeTranslated.cs | 33 ++++++++++++++----- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/ALCops.Common/Extensions/SymbolInterfaceExtensions.cs b/src/ALCops.Common/Extensions/SymbolInterfaceExtensions.cs index 9c2ce267..e794bc8a 100644 --- a/src/ALCops.Common/Extensions/SymbolInterfaceExtensions.cs +++ b/src/ALCops.Common/Extensions/SymbolInterfaceExtensions.cs @@ -125,6 +125,12 @@ public static bool IsRemoved(this ISymbol symbol) } return false; } + + /// + /// Returns true when the symbol has ObsoleteState = Moved (false on SDKs without the state). + /// + public static bool IsMoved(this ISymbol symbol) => + GetObsoletePropertyValue(symbol, _isObsoleteMovedProperty.Value); #endregion #if NETSTANDARD2_1 diff --git a/src/ALCops.LinterCop/Analyzers/TranslatableTextShouldBeTranslated.cs b/src/ALCops.LinterCop/Analyzers/TranslatableTextShouldBeTranslated.cs index 0970a48c..39082dd8 100644 --- a/src/ALCops.LinterCop/Analyzers/TranslatableTextShouldBeTranslated.cs +++ b/src/ALCops.LinterCop/Analyzers/TranslatableTextShouldBeTranslated.cs @@ -93,10 +93,15 @@ private static void OnCompilationStart(CompilationStartAnalysisContext context) private static void AnalyzeSymbol(SymbolAnalysisContext ctx, TranslationIndex translationIndex, bool useCanonicalPropertyName) { - if (ctx.IsObsolete()) + ISymbol symbol = ctx.Symbol; + + // The compiler's XLIFF generation skips Moved objects entirely, but still emits trans-units + // for Pending symbols and for labels inside obsolete objects, so this rule must not use the + // general IsObsolete() gate. GetContainingObjectTypeSymbol returns null when no object type + // is in the containment chain. + if (symbol.GetContainingObjectTypeSymbol()?.IsMoved() == true) return; - ISymbol symbol = ctx.Symbol; SymbolKind kind = symbol.Kind; if (kind == EnumProvider.SymbolKind.LocalVariable || kind == EnumProvider.SymbolKind.GlobalVariable) @@ -153,9 +158,6 @@ private static void AnalyzeLabelVariable(SymbolAnalysisContext ctx, ISymbol symb private static void AnalyzeReportLabel(SymbolAnalysisContext ctx, ISymbol symbol, TranslationIndex translationIndex) { - if (symbol.ContainingSymbol?.IsObsolete() == true) - return; - if (IsPropertyLocked(symbol)) return; @@ -176,7 +178,7 @@ private static void AnalyzePageLikeSymbol(SymbolAnalysisContext ctx, ISymbol sym { foreach (IControlSymbol control in controls) { - if (control.IsObsolete()) + if (control.IsRemoved()) continue; ReportTranslatableProperty(ctx, control, EnumProvider.PropertyKind.Caption, translationIndex, useCanonicalPropertyName); @@ -190,7 +192,7 @@ private static void AnalyzePageLikeSymbol(SymbolAnalysisContext ctx, ISymbol sym { foreach (IActionSymbol action in actions) { - if (action.IsObsolete()) + if (action.IsRemoved()) continue; ReportTranslatableProperty(ctx, action, EnumProvider.PropertyKind.Caption, translationIndex, useCanonicalPropertyName); @@ -203,7 +205,7 @@ private static void AnalyzePageLikeSymbol(SymbolAnalysisContext ctx, ISymbol sym { foreach (ISymbol analysisView in analysisViews) { - if (analysisView.IsObsolete()) + if (analysisView.IsRemoved()) continue; ReportTranslatableProperty(ctx, analysisView, EnumProvider.PropertyKind.Caption, translationIndex, useCanonicalPropertyName); @@ -218,7 +220,7 @@ private static void ReportTranslatableProperty(SymbolAnalysisContext ctx, ISymbo if (property is null) return; - if (property.ContainingSymbol?.IsObsolete() == true) + if (IsLockedByCompiler(property.ContainingSymbol)) return; if (IsPropertyLocked(property)) @@ -241,6 +243,19 @@ private static void ReportTranslatableProperty(SymbolAnalysisContext ctx, ISymbo ReportMissingTranslation(ctx, property, translationId, translationIndex); } + // Mirrors the compiler's LabelWriterVisitor.IsObsolete: a field is locked when it or its + // containing table is Removed; every other symbol only when it is Removed itself. Pending never locks. + private static bool IsLockedByCompiler(ISymbol? symbol) + { + if (symbol is null) + return false; + + if (symbol.IsRemoved()) + return true; + + return symbol.Kind == EnumProvider.SymbolKind.Field && symbol.ContainingSymbol?.IsRemoved() == true; + } + private static void ReportMissingTranslation(SymbolAnalysisContext ctx, ISymbol symbol, string translationId, TranslationIndex translationIndex) { HashSet missingLanguages = translationIndex.GetMissingLanguages(translationId); From 7fcf55c696a51708f4e1a6c5d0c1aab196335608 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sun, 27 Sep 2026 06:58:11 +0200 Subject: [PATCH 3/4] docs: record LC0091 compiler-parity obsolete handling Co-Authored-By: Claude Fable 5.1 --- .claude/rules/analyzer-development.md | 2 +- .../lc0091-translatable-text-should-be-translated.md | 9 ++++++++- REVIEW.md | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.claude/rules/analyzer-development.md b/.claude/rules/analyzer-development.md index a0c468a8..0e668140 100644 --- a/.claude/rules/analyzer-development.md +++ b/.claude/rules/analyzer-development.md @@ -18,7 +18,7 @@ The repo is version-controlled per AL release: `git -C ../nav-sdk-source tag` li ## Non-negotiables - **Plain `DiagnosticAnalyzer`, `[DiagnosticAnalyzer]`, `sealed`.** Never derive from the `ALCopsDiagnosticAnalyzer` / `{Cop}Analyzer` harness (`analyzer-exception-harness.md`). -- **`IsObsolete()` first** in every callback (available on all four analysis contexts). Reporting on obsolete code is noise. +- **`IsObsolete()` first** in every callback (available on all four analysis contexts). Reporting on obsolete code is noise. Exception: a rule that reproduces a compiler pass follows that pass's own obsolete handling and records it in its rule doc (LC0091 mirrors XLIFF generation). - **`EnumProvider` for every SDK enum value** (`ALCops.Common.Reflection`). Direct `SymbolKind.X` / `PropertyKind.X` references break on other SDK versions. A member missing from the loaded SDK resolves to an inert fallback: `default(T)` for most enums, an out-of-range sentinel for `SymbolKind`, `ActionKind` and `ControlKind`, whose zero member is a dispatchable value (`Module`, `Area`, `Area`) that an unresolved member would otherwise impersonate; the driver ignores kinds above the enum's maximum. Guard with `!= default` only for enums whose zero member is `None`; never for those three. - **Typed property access.** `GetEnumPropertyValue(EnumProvider.PropertyKind.X)`, `GetBooleanPropertyValue()`, `GetProperty()`. Never compare `ValueText` strings for property values. - **`GetSymbolSafe()`, never `GetSymbol()`**, on operations. `symbol-resolution.md` explains the SDK bug. diff --git a/.claude/rules/diagnostics/lc0091-translatable-text-should-be-translated.md b/.claude/rules/diagnostics/lc0091-translatable-text-should-be-translated.md index 0aa97778..d397014f 100644 --- a/.claude/rules/diagnostics/lc0091-translatable-text-should-be-translated.md +++ b/.claude/rules/diagnostics/lc0091-translatable-text-should-be-translated.md @@ -33,12 +33,14 @@ Registers `CompilationStartAction` (XLIFF files parsed once into a `TranslationI | Locked detection is syntactic (`CommaSeparatedIdentifierEqualsLiteralList`) | Label sub-properties are not exposed as semantic symbols. | | Empty target or `state="needs-translation"` counts as missing | Neither is a usable translation. | | Analysis views read via reflection (`FlattenedAnalysisViews` / `AddedAnalysisViewsFlattened`) | The properties exist only in the net10.0+ SDK; reflection avoids a compile-time dependency. | +| Obsolete gate mirrors the compiler's `LabelWriterVisitor.IsObsolete` instead of the house `IsObsolete()` check: skip only a Moved containing object, and properties whose symbol is Removed (or a field whose table is Removed) | The compiler still emits trans-units for Pending symbols and for labels inside obsolete objects; skipping them hides units the XLIFF tooling will flag as untranslated. Labels are only ever locked by their own `Locked = true`. | | net8.0-only: netstandard2.1 compiles an empty stub | `ExtensionObjectFoldingUtilities` and `GetLabelTextConstLanguageSymbolId` are absent there and `GetLanguageSymbolId` is internal with a different signature; there is nothing to reflect into. | ## Deliberate non-reports - Locked labels: intentionally untranslated. -- Obsolete symbols (standard ALCops convention). +- Captions and tooltips of Removed tables and fields, including fields inside a Removed table and table-extension fields whose target table is Removed: the compiler drops their trans-units. +- Everything inside a Moved object: the compiler does not visit it. - Compilations whose manifest disables translation file generation (`ShouldGenerateTranslationFile()` false), or with no XLIFF files / no target languages after the `LanguagesToTranslate` filter. ## Known issues @@ -57,12 +59,17 @@ Registers `CompilationStartAction` (XLIFF files parsed once into a `TranslationI - `ExtensionObjectFoldingUtilities.GetTranslationRootSymbol`: non-extension objects and customizations return themselves; an extension in the same module as its target folds into the target; multiple extensions on one target fold into the one with the lowest ID. - `ManifestHelper.GetManifest(compilation)` loads `Microsoft.Dynamics.Nav.Analyzers.Common` via reflection. - `manifest.CompilerFeatures.ShouldGenerateTranslationFile()` is false unless `app.json` lists `"TranslationFile"` in `features` (mapped by `CompilerFeaturesExtensions.GetCompilerFeature`). +- XLIFF generation (`Translation/LabelWriterVisitor.cs`): `IsObsolete` locks a `Field` when it or its `ContainingSymbol` is `IsObsoleteRemoved`, and any other symbol only when it is `IsObsoleteRemoved` itself; it never walks further up and never reads `IsObsoletePending`. `ShouldSymbolBeVisited` returns false for `IsObsoleteMoved`, so a Moved object's whole subtree is never emitted; `PendingMove` is neither skipped nor locked. `VisitVariable` and `VisitReportLabel` pass no lock flag, so a label is locked only by its own `Locked = true`. `XliffOutputter.WriteLabel` drops locked units (or writes them with `translate="no"` under `GenerateLockedTranslations`). +- `Symbol.IsObsoleteRemoved` is virtual `false`, overridden only by `FieldSymbol`, `KeySymbol`, `TableTypeSymbol`, `TableExtensionTypeSymbol` (forwards to `Target`), `RecordTypeSymbol` and `SynthesizedKeySymbol`; pages, controls, actions, enums, reports and the other object kinds can never be Removed. `FieldSymbol` inherits Removed/Pending from the target table in a table extension. +- No public SDK API exposes "locked by the compiler": `LabelWriterVisitor` is internal and `IsObsolete` private, hence the mirror in `IsLockedByCompiler`. +- `GetContainingObjectTypeSymbol()` returns the symbol itself for an object type and null when the containment chain ends without one. - The netstandard2.1 SDK has no `ExtensionObjectFoldingUtilities`, no `GetLabelTextConstLanguageSymbolId`, and only an internal `GetLanguageSymbolId(Symbol, Boolean, Boolean)`. ## Test notes - Every test starts with `RequireMinimumVersion("16.0")` (net8.0-only APIs); analysis-view cases are additionally gated to the net10.0 SDK, and namespace cases enable `TranslationsWithNamespaces` reflectively so the project compiles on SDKs lacking the enum member. - Fixtures use a `MemoryFileSystem`: an empty `Translations/TestApp.da-DK.xlf` makes every translatable element missing; a no-file variant covers the no-XLIFF exit; `alcops.json` is injected the same way for `LanguagesToTranslate` cases, so no `TearDown`/`ClearCache` is needed. +- Obsolete-state fixtures share the empty-XLIFF fixture. A field with `ObsoleteState` must not be in the primary key (AL0693 fails the fixture), so field-level fixtures declare a separate first field. A tableextension of a Removed table compiles, so the `TableExtensionFieldOnObsoleteRemovedTable` fixture guards the inherited-Removed path. - Legacy-runtime fixtures inject `app.json` with `"runtime": "5.1"` and `"features": ["TranslationFile"]`; without the feature the analyzer short-circuits and hides the regression. They also need `Microsoft.Dynamics.Nav.Analyzers.Common.dll` in the test bin (`` in the test csproj) for `ManifestHelper`. ## Settings diff --git a/REVIEW.md b/REVIEW.md index 55fa00e7..24108a08 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -9,7 +9,7 @@ Severity: a finding is a **correctness** finding when it changes what a rule rep | Check | Why | Reasoning lives in | |---|---|---| | An analyzer that is not `[DiagnosticAnalyzer] public sealed class X : DiagnosticAnalyzer`, or that derives from `ALCopsDiagnosticAnalyzer` / `{Cop}Analyzer` | Deriving from a Common-based type makes `alc` fail with `AL1003`; the harness is test-only. | `.claude/rules/analyzer-exception-harness.md`, `.claude/rules/analyzer-development.md` | -| A callback that does not start with the `IsObsolete()` check | Reporting on obsolete code is noise the project has decided against. | `.claude/rules/analyzer-development.md` | +| A callback that does not start with the `IsObsolete()` check, unless the rule doc records a compiler-parity exception (LC0091) | Reporting on obsolete code is noise the project has decided against. | `.claude/rules/analyzer-development.md` | | A direct `SymbolKind.X`, `PropertyKind.X`, `OperationKind.X`, `SyntaxKind.X` or `NavTypeKind.X` reference instead of `EnumProvider` | Enum ordinals differ between the SDK versions the same binary runs against; `EnumProvider` resolves by name with an inert fallback. Also check `!= default` guards on `SymbolKind` (its default is `Module`). | `.claude/rules/analyzer-development.md`, `.claude/rules/netstandard21-compatibility.md` | | `GetSymbol()` on an operation instead of `GetSymbolSafe()`; `ValueText` or `ToString()` comparisons to identify symbols or property values; raw `StringComparison.OrdinalIgnoreCase` on AL identifiers instead of `SemanticFacts` | `GetSymbol()` throws on some operation shapes; text comparison misses quoted, qualified and namespaced identifiers; AL name equality has its own rules. | `.claude/rules/symbol-resolution.md` | | A rule that touches record receivers (fields, record methods or user procedures) but handles only the named-variable form; a `Rec` check that assumes a global variable; a non-null `Instance` gate; fixtures that lack the `NamedVariable` / `RecSelf` / `BareSelf` / `ThisSelf` set, the tableextension variant, the `TableNo` `OnRun` variant, the page variant where the rule can apply to pages, or a namespaced file | The four receiver forms bind differently, `Rec` is a local in a `TableNo` codeunit's `OnRun` and a global everywhere else, bare self is a null instance only inside tables and tableextensions; missing one is the most common false-negative report. | `.claude/rules/receiver-forms.md` | From fd04b54feca357e9b3d5c20ad06f97cf121d36d2 Mon Sep 17 00:00:00 2001 From: Arthur van de Vondervoort Date: Sun, 27 Sep 2026 07:13:23 +0200 Subject: [PATCH 4/4] docs(LC0091): state that the compiler-lock mirror also covers Moved symbols The helper comment described only the Removed case while IsRemoved() also returns true for Moved, which mirrors ShouldSymbolBeVisited skipping a Moved table or field. The rule doc now lists Moved fields among the deliberate non-reports. Co-Authored-By: Claude Fable 5.1 --- .../lc0091-translatable-text-should-be-translated.md | 2 +- .../Analyzers/TranslatableTextShouldBeTranslated.cs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.claude/rules/diagnostics/lc0091-translatable-text-should-be-translated.md b/.claude/rules/diagnostics/lc0091-translatable-text-should-be-translated.md index d397014f..988ba035 100644 --- a/.claude/rules/diagnostics/lc0091-translatable-text-should-be-translated.md +++ b/.claude/rules/diagnostics/lc0091-translatable-text-should-be-translated.md @@ -40,7 +40,7 @@ Registers `CompilationStartAction` (XLIFF files parsed once into a `TranslationI - Locked labels: intentionally untranslated. - Captions and tooltips of Removed tables and fields, including fields inside a Removed table and table-extension fields whose target table is Removed: the compiler drops their trans-units. -- Everything inside a Moved object: the compiler does not visit it. +- Everything inside a Moved object, and a Moved field in a live table: the compiler does not visit them (`FieldSymbol` and `TableTypeSymbol` are the only symbols that can be Moved). - Compilations whose manifest disables translation file generation (`ShouldGenerateTranslationFile()` false), or with no XLIFF files / no target languages after the `LanguagesToTranslate` filter. ## Known issues diff --git a/src/ALCops.LinterCop/Analyzers/TranslatableTextShouldBeTranslated.cs b/src/ALCops.LinterCop/Analyzers/TranslatableTextShouldBeTranslated.cs index 39082dd8..b274a70c 100644 --- a/src/ALCops.LinterCop/Analyzers/TranslatableTextShouldBeTranslated.cs +++ b/src/ALCops.LinterCop/Analyzers/TranslatableTextShouldBeTranslated.cs @@ -243,8 +243,10 @@ private static void ReportTranslatableProperty(SymbolAnalysisContext ctx, ISymbo ReportMissingTranslation(ctx, property, translationId, translationIndex); } - // Mirrors the compiler's LabelWriterVisitor.IsObsolete: a field is locked when it or its - // containing table is Removed; every other symbol only when it is Removed itself. Pending never locks. + // Mirrors the two ways the compiler's LabelWriterVisitor drops a symbol's trans-units: IsObsolete + // locks a field when it or its containing table is Removed and any other symbol only when it is + // Removed itself, and ShouldSymbolBeVisited skips a Moved symbol (a table or a field) outright. + // IsRemoved() covers both states. Pending never locks. private static bool IsLockedByCompiler(ISymbol? symbol) { if (symbol is null)