Skip to content

feat(LC0101,LC0102): Report database modifications inside TryFunctions - #590

Open
tkroes wants to merge 3 commits into
ALCops:mainfrom
tkroes:feat/lc0101-tryfunction-database-modifications
Open

tkroes wants to merge 3 commits into
ALCops:mainfrom
tkroes:feat/lc0101-tryfunction-database-modifications

Conversation

@tkroes

@tkroes tkroes commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds two LinterCop rules that flag database modifications inside a [TryFunction].

A TryFunction swallows runtime errors, but it does not roll back database writes made before the error. Code that writes inside a TryFunction can therefore leave the database half-updated while the caller carries on as if the call had failed cleanly.

ID Name Severity Category
LC0101 TryFunctionDatabaseModificationDisallowed Error Usage
LC0102 TryFunctionPotentialDatabaseModification Warning Usage

Both are enabled by default. Neither has a CodeFix, a setting or a version gate.

What is reported

LC0101 (Error) is raised on built-in methods that always write:
Insert, Modify, ModifyAll, Delete, DeleteAll, Rename, Truncate, AddLink, CopyLinks, DeleteLink, DeleteLinks.

LC0102 (Warning) is raised on calls that may write, depending on the target:

  • Validate, since field triggers may modify data
  • Run and Codeunit.Run
  • calls to custom procedures and other non-built-in methods

Deliberate limits

  • Only calls written directly in the TryFunction body are analyzed. The call graph is not followed, because walking it for every procedure is too expensive. LC0102 is a warning so it can be dismissed with a pragma once the callee is known to be safe.
  • Matching is by method name, so FieldRef.Validate is covered by the same rule as Record.Validate.
  • Obsolete TryFunctions and declarations without a body are skipped.
  • Calls in nested code blocks inside the TryFunction are all reported.

Implementation notes

  • One analyzer, TryFunctionDatabaseModification, registered with RegisterCodeBlockAction. It reports both diagnostics in the same callback and carries no state between callbacks.
  • TryFunctions are detected with the syntax helper MethodDeclarationSyntax.IsTryFunction(), because IMethodSymbol.IsTryFunction is not available on netstandard2.1.
  • The Insert, Modify and Delete method names come from ALCops.Common.RecordMethodClassification, and the remaining names are listed in the analyzer.

Tests

5 new fixture-based tests:

  • HasDiagnostic: all 11 direct modification methods, and the potential-modification cases.
  • NoDiagnostic: read-only methods, the same calls outside a TryFunction, and an obsolete TryFunction.

Validation:

  • LinterCop tests: 401 passing.
  • Release build with ContinuousIntegrationBuild=true: succeeds on netstandard2.1, net8.0 and net10.0.
  • dotnet format --verify-no-changes: clean.
  • Validate-Rules.ps1: passes.

Documentation

  • Rule doc added at .claude/rules/diagnostics/lc0101-lc0102-tryfunction-database-modification.md.
  • The docs-site pages in alcops.dev (content/docs/analyzers/lintercop/LC0101.md and LC0102.md) still need to be written.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@MODUSCarstenScholling

Copy link
Copy Markdown
Contributor

Sorry if I'm rushing through this. Just back from vacation today.
To avoid noise, would you consider the possible noisy LC0102 to be disabled by default? And reduce the Error to a Warning? Because there could be code that relies on DisableWriteInsideTryFunctions = false.

Just to consider.

@tkroes

tkroes commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

@MODUSCarstenScholling I will do that, these were just the defaults for our internal linters, but it makes more sense to keep these fairly low profile and only ramp them up using rulesets. Hope to get around to fixing this and my other PR sometime this week.

Arthurvdv and others added 2 commits October 26, 2026 15:18
…o 1.5.1 (ALCops#592)

* fix(ci): fail the test job when a test project does not compile

Every test step in build-test.yml was continue-on-error, so a compile
error in a test project (CS1705 from RoslynTestKit referencing a newer
SDK than the leg's floor) produced no trx and was silently skipped; the
daily build stayed green for weeks.

Each test project is now built in its own step without continue-on-error,
so a compile error fails the job at that step. `dotnet test --no-build`
(same NavTargetFramework and Configuration, so it runs the exact build
output) stays continue-on-error, so test failures still do not stop the
remaining cops and AL versions from reporting together.

The rules docs now describe the gate and the RoslynTestKit floor
contract: the per-TFM floor moves down when Microsoft publishes a
net10.0 build for an older AL major, and the remedy for CS1705 is
re-pinning and releasing RoslynTestKit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore: bump RoslynTestKit to 1.5.1 and pin the local net10.0 SDK to the 16.10 floor

RoslynTestKit 1.5.1 compiles its net10.0 lib against SDK 16.10.44.19458,
the lowest marketplace net10.0 release, so it binds on every leg at or
above the CI compile floor.

Setup-BCDevTools.ps1 now pins net10.0 to the same 16.10 VSIX so the
local compile floor equals the CI floor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(ci): name the build steps in the no-trx error and keep the floor doc to current state

The no-trx error in Merge Test Results now points at the Build steps as
well as the Setup and Run steps. The floor row in get-bc-devtools.md
drops its dated history, states the mechanism and current values, and
explains why RoslynTestKit must be compiled against an SDK at or below
the floor (CS1705).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Arthurvdv

Copy link
Copy Markdown
Member

Great work, this is an long requested diagnostic to have, see #533.

I ran a code reviews on the PR and checked the findings against the code and the Microsoft docs. Below is what I'd like to discuss before we merge. The biggest point is where the rule lives; most of the other points follow from that.

1. Move the rule to PlatformCop

LinterCop is scoped to "non-breaking code smells … where multiple valid options exist". A write inside a TryFunction isn't a style choice. It's a runtime-semantics hazard, so it belongs in PlatformCop: "AL language and runtime semantic correctness, preventing patterns that always fail or behave unpredictably … independent of the Business Central domain model". It isn't ApplicationCop either, because nothing here depends on how tables, pages or permissions are modelled.

From Handling errors using try methods:

  • Database changes made inside a try method are not rolled back when the error is caught.
  • On-premises, DisableWriteInsideTryFunctions defaults to true, so the write itself causes a runtime error.
  • Online there is no restriction. The write succeeds and stays committed even when the TryFunction returns false.

It's "fails or behaves unpredictably" rather than "always fails", which fits PlatformCop.

Concretely:

  • Rename and renumber both dianostics
  • Keep the severity at Warning, enabled by default, but only after the false positives below are fixed. A PlatformCop warning says "this is a bug", so false positives cost more there.
  • The message currently says the method "must not be called from a TryFunction". That's only true on-prem. Something like "'{0}' writes to the database inside a TryFunction; the change is not rolled back when the error is caught" describes both environments.

2. Disable LC0102 (the "potential modification" variant) by default

LC0102 reports every call to a non-built-in method inside a TryFunction (MyHelper(), IsValid(Rec), event publishers, interface calls, …), plus Commit, Validate, Run and others. There's no signal that tells a pure helper apart from one that writes, so nearly every report is noise. The severity commit set it to Hidden while keeping isEnabledByDefault: true. Hidden diagnostics don't show up in the editor or the alc output, so the rule still runs on every call but nobody sees the result. Yo can set the default severity to Info and isEnabledByDefault: false, so user should opt-in for this diagnoastic.

3. False positives: temporary records and non-record built-ins

The analyzer classifies by method name and MethodKind only, and never looks at the receiver. That produces two kinds of false positive:

codeunit 50100 MyCodeunit
{
    [TryFunction]
    local procedure TryBuildBuffer()
    var
        TempBuffer: Record MyTable temporary;
        Names: List of [Text];
        Builder: TextBuilder;
    begin
        TempBuffer.Insert();        // reported, but a temporary record never touches the database
        TempBuffer.DeleteAll();     // reported, same
        Names.Insert(1, 'x');       // reported: List.Insert is a BuiltInMethod named "Insert"
        Builder.Insert(1, 'x');     // reported: TextBuilder.Insert, same
    end;
}

Records whose table has TableType = Temporary have the same problem.

Common already has what's needed:

  • GetReceiverTableType(...) in OperationExtensions resolves the receiver for every form: MyRec.Insert(), Rec.Insert(), this.Insert() and a bare Insert() in a table. It returns null when the receiver isn't a record (List, TextBuilder, HttpClient, …).
  • IRecordTypeSymbol.IsTemporary() / ITableTypeSymbol.IsTemporary() cover both the temporary keyword and TableType = Temporary.

RecordRef (RecRef.Insert()) isn't a record receiver for this helper, so it would not be reported. Whether a RecordRef is temporary is only known at runtime, so I'd keep that as a documented limitation rather than report it and risk false positives.

4. False negative: calls without parentheses

AL lets you write MyRecord.Insert;, MyRecord.DeleteAll; or a bare Modify; without parentheses. For those the parser produces a MemberAccessExpressionSyntax (or a lone IdentifierNameSyntax) instead of an InvocationExpressionSyntax. The current DescendantNodes().OfType<InvocationExpressionSyntax>() walk never sees them, so the rule silently misses these writes.

Walking operations instead of syntax fixes this: IInvocationExpression represents both spellings. It's also what GetReceiverTableType expects, so points 3 and 4 have the same fix (see the code under 5).

5. Simplify the analyzer

A few smaller things in AnalyzeCodeBlock:

  • ctx.CodeBlock is not MethodOrTriggerDeclarationSyntax methodSyntax || methodSyntax is not MethodDeclarationSyntax methodDeclaration matches the same node twice, and methodSyntax isn't used afterwards. A single property pattern does the same job.
  • IsDirectDatabaseModificationMethod checks up to four hash sets on every invocation. Build one set once in the static initializer instead. ImmutableHashSet.Union keeps the SemanticFacts.NameEqualityComparer of the source set.
  • With LC0102 gone, the PotentialDatabaseModificationMethods set and the second branch disappear.

Put together with 3 and 4, the analyzer could look roughly like this (sketchy and untested):

using System.Collections.Immutable;
using ALCops.Common;
using ALCops.Common.Extensions;
using ALCops.Common.Reflection;
using Microsoft.Dynamics.Nav.CodeAnalysis;
using Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics;
using Microsoft.Dynamics.Nav.CodeAnalysis.Semantics;
using Microsoft.Dynamics.Nav.CodeAnalysis.Syntax;

namespace ALCops.PlatformCop.Analyzers;

[DiagnosticAnalyzer]
public sealed class TryFunctionDatabaseWrite : DiagnosticAnalyzer
{
    private static readonly ImmutableHashSet<string> DatabaseWriteMethods =
        RecordMethodClassification.InsertMethods
            .Union(RecordMethodClassification.ModifyMethods)
            .Union(RecordMethodClassification.DeleteMethods)
            .Union(new[] { "Truncate", "AddLink", "CopyLinks", "DeleteLink", "DeleteLinks" });

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } =
        ImmutableArray.Create(DiagnosticDescriptors.TryFunctionDatabaseWrite);

    public override void Initialize(AnalysisContext context) =>
        context.RegisterCodeBlockAction(AnalyzeCodeBlock);

    private static void AnalyzeCodeBlock(CodeBlockAnalysisContext ctx)
    {
        if (ctx.IsObsolete()
            || ctx.CodeBlock is not MethodDeclarationSyntax { Body: { } body } method
            || !method.IsTryFunction()
            || ctx.SemanticModel.GetOperation(body, ctx.CancellationToken) is not { } operation)
        {
            return;
        }

        new DatabaseWriteWalker(ctx).Visit(operation);
    }

    private sealed class DatabaseWriteWalker : OperationWalker
    {
        private readonly CodeBlockAnalysisContext _ctx;

        public DatabaseWriteWalker(CodeBlockAnalysisContext ctx) => _ctx = ctx;

        // IInvocationExpression covers both MyRec.Insert() and MyRec.Insert (no parentheses).
        public override void VisitInvocationExpression(IInvocationExpression operation)
        {
            _ctx.CancellationToken.ThrowIfCancellationRequested();

            if (operation.TargetMethod.MethodKind == EnumProvider.MethodKind.BuiltInMethod
                && DatabaseWriteMethods.Contains(operation.TargetMethod.Name)
                // null for non-record receivers (List, TextBuilder, HttpClient, RecordRef, ...)
                && operation.GetReceiverTableType(_ctx.OwningSymbol, out var recordType) is { } table
                // temporary records never write to the database
                && !table.IsTemporary()
                && recordType?.IsTemporary() != true)
            {
                _ctx.ReportDiagnostic(Diagnostic.Create(
                    DiagnosticDescriptors.TryFunctionDatabaseWrite,
                    operation.Syntax.GetLocation(),
                    operation.TargetMethod.Name));
            }

            base.VisitInvocationExpression(operation);
        }
    }
}

6. Tests

Add regression fixtures for the cases above, so they can't come back:

  • NoDiagnostic/TemporaryRecord.al: temporary variable and a TableType = Temporary table.
  • NoDiagnostic/NonRecordBuiltIns.al: List.Insert, TextBuilder.Insert, HttpClient.Delete.
  • HasDiagnostic/WithoutParentheses.al: MyRecord.Insert;, MyRecord.DeleteAll;.
  • HasDiagnostic/ImplicitRec.al: a bare Insert() / Modify; inside a TryFunction in a table.

For example:

codeunit 50100 TryFuncTemporaryRecord
{
    [TryFunction]
    local procedure TryFillBuffer()
    var
        TempMyRecord: Record MyTable temporary;
        MyTempTable: Record MyTempTable;
    begin
        [|TempMyRecord.Insert()|];
        [|TempMyRecord.DeleteAll()|];
        [|MyTempTable.Insert()|];
    end;
}

table 50100 MyTable
{
    fields { field(1; MyField; Integer) { } }
}

table 50101 MyTempTable
{
    TableType = Temporary;
    fields { field(1; MyField; Integer) { } }
}
codeunit 50100 TryFuncWithoutParentheses
{
    [TryFunction]
    local procedure TryWork()
    var
        MyRecord: Record MyTable;
    begin
        [|MyRecord.Insert|];
        [|MyRecord.DeleteAll|];
    end;
}

table 50100 MyTable
{
    fields { field(1; MyField; Integer) { } }
}

7. Housekeeping

  • Rebase on main. The branch starts before fix(ci): fail the test job on compile errors and bump RoslynTestKit to 1.5.1 #592 was merged, so the build-test.yml, Directory.Packages.props, Setup-BCDevTools.ps1, get-bc-devtools.md and release-strategy.md hunks show up in this PR. Their content is already on main, so after a rebase they drop out and the PR only contains the rule.
  • Commit message. XXXX: Set TryFunction diagnostic severities still has the placeholder prefix. With the move it'll be squashed anyway; the PR title should become something like feat(PC0040): report database writes inside TryFunctions.
  • Docs page. Every rule needs a page in alcops.dev at content/docs/analyzers/PlatformCop/PC0040.md, otherwise the help link 404s. Please open that as a companion PR and link it here.

Happy to help with any of this of you need any help, feel free to reach out. Again thank you for this great rule 🤗

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants