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..988ba035 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, 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 @@ -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` | 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.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", diff --git a/src/ALCops.LinterCop/Analyzers/TranslatableTextShouldBeTranslated.cs b/src/ALCops.LinterCop/Analyzers/TranslatableTextShouldBeTranslated.cs index 0970a48c..b274a70c 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,21 @@ private static void ReportTranslatableProperty(SymbolAnalysisContext ctx, ISymbo ReportMissingTranslation(ctx, property, translationId, translationIndex); } + // 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) + 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);