Repository navigation
Conversation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Sorry if I'm rushing through this. Just back from vacation today. Just to consider. |
|
@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. |
…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>
|
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 PlatformCopLinterCop 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:
It's "fails or behaves unpredictably" rather than "always fails", which fits PlatformCop. Concretely:
2. Disable LC0102 (the "potential modification" variant) by defaultLC0102 reports every call to a non-built-in method inside a TryFunction ( 3. False positives: temporary records and non-record built-insThe analyzer classifies by method name and 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 Common already has what's needed:
4. False negative: calls without parenthesesAL lets you write Walking operations instead of syntax fixes this: 5. Simplify the analyzerA few smaller things in
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. TestsAdd regression fixtures for the cases above, so they can't come back:
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
Happy to help with any of this of you need any help, feel free to reach out. Again thank you for this great rule 🤗 |
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.
TryFunctionDatabaseModificationDisallowedTryFunctionPotentialDatabaseModificationBoth 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 dataRunandCodeunit.RunDeliberate limits
FieldRef.Validateis covered by the same rule asRecord.Validate.Implementation notes
TryFunctionDatabaseModification, registered withRegisterCodeBlockAction. It reports both diagnostics in the same callback and carries no state between callbacks.MethodDeclarationSyntax.IsTryFunction(), becauseIMethodSymbol.IsTryFunctionis not available onnetstandard2.1.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:
ContinuousIntegrationBuild=true: succeeds onnetstandard2.1,net8.0andnet10.0.dotnet format --verify-no-changes: clean.Validate-Rules.ps1: passes.Documentation
.claude/rules/diagnostics/lc0101-lc0102-tryfunction-database-modification.md.alcops.dev(content/docs/analyzers/lintercop/LC0101.mdandLC0102.md) still need to be written.