Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/rules/analyzer-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(EnumProvider.PropertyKind.X)`, `GetBooleanPropertyValue()`, `GetProperty()`. Never compare `ValueText` strings for property values.
- **`GetSymbolSafe()`, never `GetSymbol()`**, on operations. `symbol-resolution.md` explains the SDK bug.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (`<Reference Private="True">` in the test csproj) for `ManifestHelper`.

## Settings
Expand Down
2 changes: 1 addition & 1 deletion REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
6 changes: 6 additions & 0 deletions src/ALCops.Common/Extensions/SymbolInterfaceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ public static bool IsRemoved(this ISymbol symbol)
}
return false;
}

/// <summary>
/// Returns true when the symbol has ObsoleteState = Moved (false on SDKs without the state).
/// </summary>
public static bool IsMoved(this ISymbol symbol) =>
GetObsoletePropertyValue(symbol, _isObsoleteMovedProperty.Value);
#endregion

#if NETSTANDARD2_1
Expand Down
Original file line number Diff line number Diff line change
@@ -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'|];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
codeunit 50100 MyCodeunit
{
ObsoleteState = Pending;
ObsoleteReason = 'Replaced by MyNewCodeunit.';

procedure MyProcedure()
var
[|MyLabel: Label 'Hello World'|];
begin
end;
}
Original file line number Diff line number Diff line change
@@ -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]) { }
}
}
Original file line number Diff line number Diff line change
@@ -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'|];
}
}
}
Original file line number Diff line number Diff line change
@@ -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.';
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
report 50100 MyReport
{
ObsoleteState = Pending;
ObsoleteReason = 'Replaced by MyNewReport.';

labels
{
[|MyReportLabel = 'Report Label Text'|];
}
}
Original file line number Diff line number Diff line change
@@ -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|];
}
Original file line number Diff line number Diff line change
@@ -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'|];
}
}
}
Original file line number Diff line number Diff line change
@@ -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.';
}
}
}
Original file line number Diff line number Diff line change
@@ -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'|];
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;

Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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))
Expand All @@ -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<string> missingLanguages = translationIndex.GetMissingLanguages(translationId);
Expand Down
Loading