From 36609bd96b9e26156f2ce135223b4bc3acc10c37 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Mon, 3 Nov 2025 14:06:06 +0100 Subject: [PATCH 01/22] Core(Tests): add NoAsyncRunSynchronouslyInLibrary Rule and tests for it. --- src/FSharpLint.Core/FSharpLint.Core.fsproj | 1 + .../NoAsyncRunSynchronouslyInLibrary.fs | 30 ++++++++++++++++ src/FSharpLint.Core/Rules/Identifiers.fs | 1 + .../FSharpLint.Core.Tests.fsproj | 1 + .../NoAsyncRunSynchronouslyInLibrary.fs | 35 +++++++++++++++++++ 5 files changed, 68 insertions(+) create mode 100644 src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs create mode 100644 tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs diff --git a/src/FSharpLint.Core/FSharpLint.Core.fsproj b/src/FSharpLint.Core/FSharpLint.Core.fsproj index 6f0f5d10e..456ed59ee 100644 --- a/src/FSharpLint.Core/FSharpLint.Core.fsproj +++ b/src/FSharpLint.Core/FSharpLint.Core.fsproj @@ -69,6 +69,7 @@ + diff --git a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs new file mode 100644 index 000000000..a1785a34c --- /dev/null +++ b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs @@ -0,0 +1,30 @@ +module FSharpLint.Rules.NoAsyncRunSynchronouslyInLibrary + +open FSharp.Compiler.Syntax +open FSharp.Compiler.Text +open FSharpLint.Framework +open FSharpLint.Framework.Suggestion +open FSharpLint.Framework.Ast +open FSharpLint.Framework.Rules +open FSharpLint.Framework.Utilities + +let checkIfInLibrary (syntaxArray: array) (range: range) : array = + failwith "Not implemented" + +let runner args = + match args.AstNode with + | AstNode.Identifier(["Async"; "RunSynchronously"], range) -> + checkIfInLibrary args.SyntaxArray range + | _ -> Array.empty + +let rule = + AstNodeRule + { + Name = "NoAsyncRunSynchronouslyInLibrary" + Identifier = Identifiers.NoAsyncRunSynchronouslyInLibrary + RuleConfig = + { + AstNodeRuleConfig.Runner = runner + Cleanup = ignore + } + } diff --git a/src/FSharpLint.Core/Rules/Identifiers.fs b/src/FSharpLint.Core/Rules/Identifiers.fs index e40b6edb6..3106e12c3 100644 --- a/src/FSharpLint.Core/Rules/Identifiers.fs +++ b/src/FSharpLint.Core/Rules/Identifiers.fs @@ -92,3 +92,4 @@ let FavourNonMutablePropertyInitialization = identifier 84 let EnsureTailCallDiagnosticsInRecursiveFunctions = identifier 85 let FavourAsKeyword = identifier 86 let InterpolatedStringWithNoSubstitution = identifier 87 +let NoAsyncRunSynchronouslyInLibrary = identifier 88 diff --git a/tests/FSharpLint.Core.Tests/FSharpLint.Core.Tests.fsproj b/tests/FSharpLint.Core.Tests/FSharpLint.Core.Tests.fsproj index 373cef93c..0923126f1 100644 --- a/tests/FSharpLint.Core.Tests/FSharpLint.Core.Tests.fsproj +++ b/tests/FSharpLint.Core.Tests/FSharpLint.Core.Tests.fsproj @@ -47,6 +47,7 @@ + diff --git a/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs new file mode 100644 index 000000000..a78975a24 --- /dev/null +++ b/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs @@ -0,0 +1,35 @@ +module FSharpLint.Core.Tests.Rules.Conventions.NoAsyncRunSynchronouslyInLibrary + +open NUnit.Framework +open FSharpLint.Framework.Rules +open FSharpLint.Rules + +[] +type TestNoAsyncRunSynchronouslyInLibrary() = + inherit FSharpLint.Core.Tests.TestAstNodeRuleBase.TestAstNodeRuleBase(NoAsyncRunSynchronouslyInLibrary.rule) + + [] + member this.``Async.RunSynchronously should not be used in library code``() = + this.Parse(""" +module Program + +async { + return () +} +|> Async.RunSynchronously""") + + Assert.IsTrue this.ErrorsExist + + [] + member this.``Async.RunSynchronously may be used in code that declares entry point``() = + this.Parse(""" +module Program + +[] +let main () = + async { + return () + } + |> Async.RunSynchronously""") + + this.AssertNoWarnings() From 88b23fcf59619c213053d41b60ff5738f4bda805 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 4 Nov 2025 12:32:31 +0100 Subject: [PATCH 02/22] NoAsyncRunSynchronouslyInLibrary: implement rule No checks for assembly name yet. --- .../NoAsyncRunSynchronouslyInLibrary.fs | 28 ++++++++++++++++++- src/FSharpLint.Core/Text.resx | 3 ++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs index a1785a34c..f58d9c74d 100644 --- a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs +++ b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs @@ -8,8 +8,34 @@ open FSharpLint.Framework.Ast open FSharpLint.Framework.Rules open FSharpLint.Framework.Utilities +let hasEntryPointAttribute (syntaxArray: array) = + let hasEntryPoint (attrs: SynAttributeList) = + attrs.Attributes + |> List.exists + (fun attr -> + match attr.TypeName with + | SynLongIdent([ident], _, _) -> ident.idText = "EntryPoint" + | _ -> false) + + syntaxArray + |> Array.exists + (fun node -> + match node.Actual with + | AstNode.Binding(SynBinding(_, _, _, _, attributes, _, _, _, _, _, _, _, _)) -> + attributes |> List.exists hasEntryPoint + | _ -> false) + let checkIfInLibrary (syntaxArray: array) (range: range) : array = - failwith "Not implemented" + if hasEntryPointAttribute syntaxArray then + Array.empty + else + Array.singleton + { + Range = range + Message = Resources.GetString "NoAsyncRunSynchronouslyInLibrary" + SuggestedFix = None + TypeChecks = List.Empty + } let runner args = match args.AstNode with diff --git a/src/FSharpLint.Core/Text.resx b/src/FSharpLint.Core/Text.resx index e986c1672..24f581992 100644 --- a/src/FSharpLint.Core/Text.resx +++ b/src/FSharpLint.Core/Text.resx @@ -384,4 +384,7 @@ Do not use interpolated string syntax (with $ prefix) or formatting functions (sprintf, failwithf) when not really performing any interpolation. + + Async.RunSynchronously should not be used in libraries. + From 3c9e224c2da5b84bd742e6e139a3746a8999f8d6 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 4 Nov 2025 14:07:29 +0100 Subject: [PATCH 03/22] NoAsyncRunSynchronouslyInLibrary: check assy name Check assembly name. If it contains "test" substring, assume this is test project and don't fire the rule. --- .../NoAsyncRunSynchronouslyInLibrary.fs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs index f58d9c74d..d20dafec2 100644 --- a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs +++ b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs @@ -2,6 +2,7 @@ open FSharp.Compiler.Syntax open FSharp.Compiler.Text +open FSharp.Compiler.CodeAnalysis open FSharpLint.Framework open FSharpLint.Framework.Suggestion open FSharpLint.Framework.Ast @@ -25,8 +26,16 @@ let hasEntryPointAttribute (syntaxArray: array) = attributes |> List.exists hasEntryPoint | _ -> false) -let checkIfInLibrary (syntaxArray: array) (range: range) : array = - if hasEntryPointAttribute syntaxArray then +let checkIfInLibrary (syntaxArray: array) (checkInfo: option) (range: range) : array = + let isInTestAssembly = + match checkInfo with + | Some checkFileResults -> + match Seq.tryHead checkFileResults.PartialAssemblySignature.Entities with + | Some entity -> entity.Assembly.QualifiedName.ToLowerInvariant().Contains "test" + | None -> false + | None -> false + + if isInTestAssembly || hasEntryPointAttribute syntaxArray then Array.empty else Array.singleton @@ -40,7 +49,7 @@ let checkIfInLibrary (syntaxArray: array) (range: rang let runner args = match args.AstNode with | AstNode.Identifier(["Async"; "RunSynchronously"], range) -> - checkIfInLibrary args.SyntaxArray range + checkIfInLibrary args.SyntaxArray args.CheckInfo range | _ -> Array.empty let rule = From feb589e30be6ef77c0d5024324796d564863a9ba Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Wed, 5 Nov 2025 09:51:50 +0100 Subject: [PATCH 04/22] Core(Tests): add 2 more tests For NoAsyncRunSynchronouslyInLibrary rule that make sure that code inside NUnit and MSTest tests doesn't trigger the rule. Includes failing tests. --- .../NoAsyncRunSynchronouslyInLibrary.fs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs index a78975a24..ecb04dc0b 100644 --- a/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs +++ b/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs @@ -33,3 +33,35 @@ let main () = |> Async.RunSynchronously""") this.AssertNoWarnings() + + [] + member this.``Async.RunSynchronously may be used in NUnit test code``() = + this.Parse(""" +module Program + +[] +type FooTest () = + [] + member this.Foo() = + async { + return () + } + |> Async.RunSynchronously""") + + this.AssertNoWarnings() + + [] + member this.``Async.RunSynchronously may be used in MSTest test code``() = + this.Parse(""" +module Program + +[] +type FooTest () = + [] + member this.Foo() = + async { + return () + } + |> Async.RunSynchronously""") + + this.AssertNoWarnings() From 73edbf2b2de74905aadd9596b0bc2cef1d90732d Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Wed, 5 Nov 2025 12:35:27 +0100 Subject: [PATCH 05/22] NoAsyncRunSynchronouslyInLibrary: check for test Check if Async.RunSynchronously call is iniside NUnit or MSTest tests. If it is, don't trigger the rule. --- .../NoAsyncRunSynchronouslyInLibrary.fs | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs index d20dafec2..32b157ff7 100644 --- a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs +++ b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs @@ -9,33 +9,53 @@ open FSharpLint.Framework.Ast open FSharpLint.Framework.Rules open FSharpLint.Framework.Utilities -let hasEntryPointAttribute (syntaxArray: array) = - let hasEntryPoint (attrs: SynAttributeList) = - attrs.Attributes - |> List.exists - (fun attr -> - match attr.TypeName with - | SynLongIdent([ident], _, _) -> ident.idText = "EntryPoint" - | _ -> false) +let extractAttributeNames (attributes: SynAttributes) = + seq { + for attr in extractAttributes attributes do + match attr.TypeName with + | SynLongIdent([ident], _, _) -> yield ident.idText + | _ -> () + } +let hasEntryPointAttribute (syntaxArray: array) = syntaxArray |> Array.exists (fun node -> match node.Actual with | AstNode.Binding(SynBinding(_, _, _, _, attributes, _, _, _, _, _, _, _, _)) -> - attributes |> List.exists hasEntryPoint + attributes + |> extractAttributeNames + |> Seq.contains "EntryPoint" | _ -> false) -let checkIfInLibrary (syntaxArray: array) (checkInfo: option) (range: range) : array = +let testMethodAttributes = [ "Test"; "TestMethod" ] +let testClassAttributes = [ "TestFixture"; "TestClass" ] + +let isInsideTest (parents: list) = + let isTestMethodOrClass node = + match node with + | AstNode.MemberDefinition(SynMemberDefn.Member(SynBinding(_, _, _, _, attributes, _, _, _, _, _, _, _, _), _)) -> + attributes + |> extractAttributeNames + |> Seq.exists (fun name -> testMethodAttributes |> List.contains name) + | AstNode.TypeDefinition(SynTypeDefn.SynTypeDefn(SynComponentInfo(attributes, _, _, _, _, _, _, _), _, _, _, _, _)) -> + attributes + |> extractAttributeNames + |> Seq.exists (fun name -> testClassAttributes |> List.contains name) + | _ -> false + + parents |> List.exists isTestMethodOrClass + +let checkIfInLibrary (args: AstNodeRuleParams) (range: range) : array = let isInTestAssembly = - match checkInfo with + match args.CheckInfo with | Some checkFileResults -> match Seq.tryHead checkFileResults.PartialAssemblySignature.Entities with | Some entity -> entity.Assembly.QualifiedName.ToLowerInvariant().Contains "test" | None -> false | None -> false - if isInTestAssembly || hasEntryPointAttribute syntaxArray then + if isInTestAssembly || isInsideTest (args.GetParents args.NodeIndex) || hasEntryPointAttribute args.SyntaxArray then Array.empty else Array.singleton @@ -49,7 +69,7 @@ let checkIfInLibrary (syntaxArray: array) (checkInfo: let runner args = match args.AstNode with | AstNode.Identifier(["Async"; "RunSynchronously"], range) -> - checkIfInLibrary args.SyntaxArray args.CheckInfo range + checkIfInLibrary args range | _ -> Array.empty let rule = From f1cfe3bb5edb7ef1e76a3bcb22a969d86dd654db Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Wed, 5 Nov 2025 13:21:08 +0100 Subject: [PATCH 06/22] NoAsyncRunSynchronouslyInLibrary: add config Add configuration for the rule and enable it by default. --- src/FSharpLint.Core/Application/Configuration.fs | 5 ++++- src/FSharpLint.Core/fsharplint.json | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/FSharpLint.Core/Application/Configuration.fs b/src/FSharpLint.Core/Application/Configuration.fs index e211e6113..bb07ed237 100644 --- a/src/FSharpLint.Core/Application/Configuration.fs +++ b/src/FSharpLint.Core/Application/Configuration.fs @@ -474,7 +474,8 @@ type Configuration = SuggestUseAutoProperty:EnabledConfig option EnsureTailCallDiagnosticsInRecursiveFunctions:EnabledConfig option FavourAsKeyword:EnabledConfig option - InterpolatedStringWithNoSubstitution:EnabledConfig option } + InterpolatedStringWithNoSubstitution:EnabledConfig option + NoAsyncRunSynchronouslyInLibrary:EnabledConfig option} with static member Zero = { Global = None @@ -570,6 +571,7 @@ with EnsureTailCallDiagnosticsInRecursiveFunctions = None FavourAsKeyword = None InterpolatedStringWithNoSubstitution = None + NoAsyncRunSynchronouslyInLibrary = None } // fsharplint:enable RecordFieldNames @@ -766,6 +768,7 @@ let flattenConfig (config:Configuration) = config.EnsureTailCallDiagnosticsInRecursiveFunctions |> Option.bind (constructRuleIfEnabled EnsureTailCallDiagnosticsInRecursiveFunctions.rule) config.FavourAsKeyword |> Option.bind (constructRuleIfEnabled FavourAsKeyword.rule) config.InterpolatedStringWithNoSubstitution |> Option.bind (constructRuleIfEnabled InterpolatedStringWithNoSubstitution.rule) + config.NoAsyncRunSynchronouslyInLibrary |> Option.bind (constructRuleIfEnabled NoAsyncRunSynchronouslyInLibrary.rule) |] findDeprecation config deprecatedAllRules allRules diff --git a/src/FSharpLint.Core/fsharplint.json b/src/FSharpLint.Core/fsharplint.json index f97777e45..36e1fad4a 100644 --- a/src/FSharpLint.Core/fsharplint.json +++ b/src/FSharpLint.Core/fsharplint.json @@ -334,6 +334,7 @@ "ensureTailCallDiagnosticsInRecursiveFunctions": { "enabled": true }, "favourAsKeyword": { "enabled": true }, "interpolatedStringWithNoSubstitution": { "enabled": true }, + "noAsyncRunSynchronouslyInLibrary": { "enabled": true }, "hints": { "add": [ "not (a = b) ===> a <> b", From 663547a76f75b16b9b6872599beba82c44a30b91 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Wed, 5 Nov 2025 13:56:43 +0100 Subject: [PATCH 07/22] NoAsyncRunSynchronouslyInLibrary: fix assy check Assembly.QualifiedName is empty, so instead check namespace and project name. --- .../Conventions/NoAsyncRunSynchronouslyInLibrary.fs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs index 32b157ff7..b8326b5ca 100644 --- a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs +++ b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs @@ -47,15 +47,18 @@ let isInsideTest (parents: list) = parents |> List.exists isTestMethodOrClass let checkIfInLibrary (args: AstNodeRuleParams) (range: range) : array = - let isInTestAssembly = + let isInTestProject = match args.CheckInfo with | Some checkFileResults -> - match Seq.tryHead checkFileResults.PartialAssemblySignature.Entities with - | Some entity -> entity.Assembly.QualifiedName.ToLowerInvariant().Contains "test" - | None -> false + let namespaceIncludesTest = + match checkFileResults.ImplementationFile with + | Some implFile -> implFile.QualifiedName.ToLowerInvariant().Contains "test" + | None -> false + let projectFileInfo = System.IO.FileInfo checkFileResults.ProjectContext.ProjectOptions.ProjectFileName + namespaceIncludesTest || projectFileInfo.Name.ToLowerInvariant().Contains "test" | None -> false - if isInTestAssembly || isInsideTest (args.GetParents args.NodeIndex) || hasEntryPointAttribute args.SyntaxArray then + if isInTestProject || isInsideTest (args.GetParents args.NodeIndex) || hasEntryPointAttribute args.SyntaxArray then Array.empty else Array.singleton From 4c6edf8a2dfa9a229291ccd52ba60c4d465bb720 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Wed, 5 Nov 2025 14:12:15 +0100 Subject: [PATCH 08/22] NoAsyncRunSynchronouslyInLibrary: refactoring Simplified check for entry point. Also combined some code that uses args.CheckInfo to not pattern match on it twice. --- .../NoAsyncRunSynchronouslyInLibrary.fs | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs index b8326b5ca..af41ae80b 100644 --- a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs +++ b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs @@ -9,6 +9,19 @@ open FSharpLint.Framework.Ast open FSharpLint.Framework.Rules open FSharpLint.Framework.Utilities +let hasEntryPoint (checkFileResults: FSharpCheckFileResults) = + match checkFileResults.ImplementationFile with + | Some implFile -> implFile.HasExplicitEntryPoint + | None -> false + +let isInTestProject (checkFileResults: FSharpCheckFileResults) = + let namespaceIncludesTest = + match checkFileResults.ImplementationFile with + | Some implFile -> implFile.QualifiedName.ToLowerInvariant().Contains "test" + | None -> false + let projectFileInfo = System.IO.FileInfo checkFileResults.ProjectContext.ProjectOptions.ProjectFileName + namespaceIncludesTest || projectFileInfo.Name.ToLowerInvariant().Contains "test" + let extractAttributeNames (attributes: SynAttributes) = seq { for attr in extractAttributes attributes do @@ -17,17 +30,6 @@ let extractAttributeNames (attributes: SynAttributes) = | _ -> () } -let hasEntryPointAttribute (syntaxArray: array) = - syntaxArray - |> Array.exists - (fun node -> - match node.Actual with - | AstNode.Binding(SynBinding(_, _, _, _, attributes, _, _, _, _, _, _, _, _)) -> - attributes - |> extractAttributeNames - |> Seq.contains "EntryPoint" - | _ -> false) - let testMethodAttributes = [ "Test"; "TestMethod" ] let testClassAttributes = [ "TestFixture"; "TestClass" ] @@ -47,18 +49,14 @@ let isInsideTest (parents: list) = parents |> List.exists isTestMethodOrClass let checkIfInLibrary (args: AstNodeRuleParams) (range: range) : array = - let isInTestProject = + let ruleNotApplicable = match args.CheckInfo with - | Some checkFileResults -> - let namespaceIncludesTest = - match checkFileResults.ImplementationFile with - | Some implFile -> implFile.QualifiedName.ToLowerInvariant().Contains "test" - | None -> false - let projectFileInfo = System.IO.FileInfo checkFileResults.ProjectContext.ProjectOptions.ProjectFileName - namespaceIncludesTest || projectFileInfo.Name.ToLowerInvariant().Contains "test" - | None -> false + | Some checkFileResults -> + hasEntryPoint checkFileResults || isInTestProject checkFileResults || isInsideTest (args.GetParents args.NodeIndex) + | None -> + isInsideTest (args.GetParents args.NodeIndex) - if isInTestProject || isInsideTest (args.GetParents args.NodeIndex) || hasEntryPointAttribute args.SyntaxArray then + if ruleNotApplicable then Array.empty else Array.singleton From 52b27c62c6fb9966dfe40339b0531a3ad2e9c766 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Thu, 6 Nov 2025 11:47:42 +0100 Subject: [PATCH 09/22] Core: remove dead code in Application/Lint.fs That also calls `Async.RunSynchronously` and causes CI to fail. --- src/FSharpLint.Core/Application/Lint.fs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/FSharpLint.Core/Application/Lint.fs b/src/FSharpLint.Core/Application/Lint.fs index da988cc96..0e6635c6c 100644 --- a/src/FSharpLint.Core/Application/Lint.fs +++ b/src/FSharpLint.Core/Application/Lint.fs @@ -285,12 +285,6 @@ module Lint = |> Array.iter trySuggest if cancelHasNotBeenRequested () then - let runSynchronously work = - let timeoutMs = 2000 - match lintInfo.CancellationToken with - | Some(cancellationToken) -> Async.RunSynchronously(work, timeoutMs, cancellationToken) - | None -> Async.RunSynchronously(work, timeoutMs) - try let typeChecksSuccessful (typeChecks:(unit -> bool) list) = (true, typeChecks) From b0d2bb94cdebe20ec38a16bcfdb97c54215907e1 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 10:08:08 +0100 Subject: [PATCH 10/22] docs: NoAsyncRunSynchronouslyInLibrary rule docs --- docs/content/how-tos/rule-configuration.md | 1 + docs/content/how-tos/rules/FL0088.md | 29 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 docs/content/how-tos/rules/FL0088.md diff --git a/docs/content/how-tos/rule-configuration.md b/docs/content/how-tos/rule-configuration.md index ceda2c93c..cc5990e68 100644 --- a/docs/content/how-tos/rule-configuration.md +++ b/docs/content/how-tos/rule-configuration.md @@ -128,3 +128,4 @@ The following rules can be specified for linting. - [EnsureTailCallDiagnosticsInRecursiveFunctions (FL0085)](rules/FL0085.html) - [FavourAsKeyword (FL0086)](rules/FL0086.html) - [InterpolatedStringWithNoSubstitution (FL0087)](rules/FL0087.html) +- [NoAsyncRunSynchronouslyInLibrary (FL0088)](rules/FL0088.html) diff --git a/docs/content/how-tos/rules/FL0088.md b/docs/content/how-tos/rules/FL0088.md new file mode 100644 index 000000000..1bff7a233 --- /dev/null +++ b/docs/content/how-tos/rules/FL0088.md @@ -0,0 +1,29 @@ +--- +title: FL0088 +category: how-to +hide_menu: true +--- + +# NoAsyncRunSynchronouslyInLibrary (FL0088) + +*Introduced in `0.26.7`* + +## Cause + +`Async.RunSynchronously` method is used to run async computation in library code. + +## Rationale + +Using `Async.RunSynchronously` outside of scripts and tests can lead to program becoming non-responsive. + +## How To Fix + +Remove `Async.RunSynchronously` and wrap the code that uses `async` computations in `async` computation, using `let!`, `use!`, `match!`, or `return!` keyword to get the result. + +## Rule Settings + + { + "noAsyncRunSynchronouslyInLibrary": { + "enabled": true + } + } From e680d1707da5b59e4adbfa55b3e0f2f60fbc66e5 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Wed, 12 Nov 2025 13:50:40 +0100 Subject: [PATCH 11/22] Core,Console,Tests: refactoring Got rid of Async.RunSynchronously calls in FSharpLint.Core. --- src/FSharpLint.Console/Program.fs | 10 +- src/FSharpLint.Core/Application/Lint.fs | 110 +++++++++++---------- src/FSharpLint.Core/Application/Lint.fsi | 11 +-- tests/FSharpLint.FunctionalTest/TestApi.fs | 12 ++- 4 files changed, 76 insertions(+), 67 deletions(-) diff --git a/src/FSharpLint.Console/Program.fs b/src/FSharpLint.Console/Program.fs index b5e59f28a..a6ebc8202 100644 --- a/src/FSharpLint.Console/Program.fs +++ b/src/FSharpLint.Console/Program.fs @@ -165,9 +165,9 @@ let private start (arguments:ParseResults) (toolsPath:Ionide.ProjInfo. try let lintResult = match fileType with - | FileType.File -> Lint.lintFile lintParams target - | FileType.Source -> Lint.lintSource lintParams target - | FileType.Solution -> Lint.lintSolution lintParams target toolsPath + | FileType.File -> Lint.asyncLintFile lintParams target |> Async.RunSynchronously + | FileType.Source -> Lint.asyncLintSource lintParams target |> Async.RunSynchronously + | FileType.Solution -> Lint.asyncLintSolution lintParams target toolsPath |> Async.RunSynchronously | FileType.Wildcard -> output.WriteInfo "Wildcard detected, but not recommended. Using a project (slnx/sln/fsproj) can detect more issues." let files = expandWildcard target @@ -176,9 +176,9 @@ let private start (arguments:ParseResults) (toolsPath:Ionide.ProjInfo. LintResult.Success List.empty else output.WriteInfo $"Found %d{List.length files} file(s) matching pattern '%s{target}'." - Lint.lintFiles lintParams files + Lint.asyncLintFiles lintParams files |> Async.RunSynchronously | FileType.Project - | _ -> Lint.lintProject lintParams target toolsPath + | _ -> Lint.asyncLintProject lintParams target toolsPath |> Async.RunSynchronously handleLintResult lintResult with | exn -> diff --git a/src/FSharpLint.Core/Application/Lint.fs b/src/FSharpLint.Core/Application/Lint.fs index 0e6635c6c..fb2cd884a 100644 --- a/src/FSharpLint.Core/Application/Lint.fs +++ b/src/FSharpLint.Core/Application/Lint.fs @@ -437,7 +437,7 @@ module Lint = /// Lints an entire F# project by retrieving the files from a given /// path to the `.fsproj` file. - let lintProject (optionalParams:OptionalLintParameters) (projectFilePath:string) (toolsPath:Ionide.ProjInfo.Types.ToolsPath) = + let asyncLintProject (optionalParams:OptionalLintParameters) (projectFilePath:string) (toolsPath:Ionide.ProjInfo.Types.ToolsPath) = async { if IO.File.Exists projectFilePath then let projectFilePath = Path.GetFullPath projectFilePath let lintWarnings = LinkedList() @@ -453,7 +453,7 @@ module Lint = let checker = FSharpChecker.Create(keepAssemblyContents=true) - let parseFilesInProject files projectOptions = + let parseFilesInProject files projectOptions = async { let lintInformation = { Configuration = config CancellationToken = optionalParams.CancellationToken @@ -467,39 +467,41 @@ module Lint = Configuration.IgnoreFiles.shouldFileBeIgnored parsedIgnoreFiles filePath) |> Option.defaultValue false - let parsedFiles = + let! parsedFiles = files |> List.filter (not << isIgnoredFile) - |> List.map (fun file -> ParseFile.parseFile file checker (Some projectOptions) |> Async.RunSynchronously) + |> List.map (fun file -> ParseFile.parseFile file checker (Some projectOptions)) + |> Async.Sequential - let failedFiles = List.choose getFailedFiles parsedFiles + let failedFiles = Array.choose getFailedFiles parsedFiles - if List.isEmpty failedFiles then + if Array.isEmpty failedFiles then parsedFiles - |> List.choose getParsedFiles - |> List.iter (lint lintInformation) + |> Array.choose getParsedFiles + |> Array.iter (lint lintInformation) - Success () + return Success () else - Failure (FailedToParseFilesInProject failedFiles) + return Failure (FailedToParseFilesInProject (Array.toList failedFiles)) + } match getProjectInfo projectFilePath toolsPath with | Ok projectOptions -> - match parseFilesInProject (Array.toList projectOptions.SourceFiles) projectOptions with - | Success _ -> lintWarnings |> Seq.toList |> LintResult.Success - | Failure lintFailure -> LintResult.Failure lintFailure + match! parseFilesInProject (Array.toList projectOptions.SourceFiles) projectOptions with + | Success _ -> return lintWarnings |> Seq.toList |> LintResult.Success + | Failure lintFailure -> return LintResult.Failure lintFailure | Error error -> - MSBuildFailedToLoadProjectFile (projectFilePath, BuildFailure.InvalidProjectFileMessage error) - |> LintResult.Failure + return + MSBuildFailedToLoadProjectFile (projectFilePath, BuildFailure.InvalidProjectFileMessage error) + |> LintResult.Failure | Error err -> - RunTimeConfigError err - |> LintResult.Failure + return RunTimeConfigError err |> LintResult.Failure else - FailedToLoadFile projectFilePath - |> LintResult.Failure + return FailedToLoadFile projectFilePath |> LintResult.Failure + } /// Lints an entire F# solution by linting all projects specified in the `.sln`, `slnx` or `.slnf` file. - let lintSolution (optionalParams:OptionalLintParameters) (solutionFilePath:string) (toolsPath:Ionide.ProjInfo.Types.ToolsPath) = + let asyncLintSolution (optionalParams:OptionalLintParameters) (solutionFilePath:string) (toolsPath:Ionide.ProjInfo.Types.ToolsPath) = async { if IO.File.Exists solutionFilePath then let solutionFilePath = Path.GetFullPath solutionFilePath let solutionFolder = Path.GetDirectoryName solutionFilePath @@ -526,9 +528,13 @@ module Lint = projectPath.Replace("\\", "/")) |> Seq.toArray - let (successes, failures) = + let! lintResults = projectsInSolution - |> Array.map (fun projectFilePath -> lintProject optionalParams projectFilePath toolsPath) + |> Array.map (fun projectFilePath -> asyncLintProject optionalParams projectFilePath toolsPath) + |> Async.Sequential + + let (successes, failures) = + lintResults |> Array.fold (fun (successes, failures) result -> match result with | LintResult.Success warnings -> @@ -538,17 +544,17 @@ module Lint = match failures with | [] -> - LintResult.Success successes + return LintResult.Success successes | firstErr :: _ -> - LintResult.Failure firstErr + return LintResult.Failure firstErr with | ex -> - LintResult.Failure (MSBuildFailedToLoadProjectFile (solutionFilePath, BuildFailure.InvalidProjectFileMessage ex.Message)) + return LintResult.Failure (MSBuildFailedToLoadProjectFile (solutionFilePath, BuildFailure.InvalidProjectFileMessage ex.Message)) - | Error err -> LintResult.Failure (RunTimeConfigError err) + | Error err -> return LintResult.Failure (RunTimeConfigError err) else - FailedToLoadFile solutionFilePath - |> LintResult.Failure + return FailedToLoadFile solutionFilePath |> LintResult.Failure + } /// Lints F# source code that has already been parsed using `FSharp.Compiler.Services` in the calling application. let lintParsedSource optionalParams parsedFileInfo = @@ -593,10 +599,6 @@ module Lint = return lintParsedSource optionalParams parsedFileInfo | ParseFile.Failed failure -> return LintResult.Failure(FailedToParseFile failure) } - - /// Lints F# source code. - let lintSource optionalParams source = - asyncLintSource optionalParams source |> Async.RunSynchronously /// Lints an F# file that has already been parsed using `FSharp.Compiler.Services` in the calling application. let lintParsedFile (optionalParams:OptionalLintParameters) (parsedFileInfo:ParsedFileInformation) (filePath:string) = @@ -627,52 +629,60 @@ module Lint = | Error err -> LintResult.Failure (RunTimeConfigError err) /// Lints an F# file from a given path to the `.fs` file. - let lintFile optionalParams filePath = + let asyncLintFile optionalParams filePath = async { if IO.File.Exists filePath then let checker = FSharpChecker.Create(keepAssemblyContents=true) - match ParseFile.parseFile filePath checker None |> Async.RunSynchronously with + match! ParseFile.parseFile filePath checker None with | ParseFile.Success astFileParseInfo -> let parsedFileInfo = { Source = astFileParseInfo.Text Ast = astFileParseInfo.Ast TypeCheckResults = astFileParseInfo.TypeCheckResults } - lintParsedFile optionalParams parsedFileInfo filePath - | ParseFile.Failed failure -> LintResult.Failure(FailedToParseFile failure) + return lintParsedFile optionalParams parsedFileInfo filePath + | ParseFile.Failed failure -> return LintResult.Failure(FailedToParseFile failure) else - FailedToLoadFile filePath - |> LintResult.Failure + return FailedToLoadFile filePath |> LintResult.Failure + } /// Lints multiple F# files from given file paths. - let lintFiles optionalParams filePaths = + let asyncLintFiles optionalParams filePaths = async { let checker = FSharpChecker.Create(keepAssemblyContents=true) match getConfig optionalParams.Configuration with | Ok config -> let optionalParams = { optionalParams with Configuration = ConfigurationParam.Configuration config } - let lintSingleFile filePath = + let lintSingleFile filePath = async { if IO.File.Exists filePath then - match ParseFile.parseFile filePath checker None |> Async.RunSynchronously with + match! ParseFile.parseFile filePath checker None with | ParseFile.Success astFileParseInfo -> let parsedFileInfo = { Source = astFileParseInfo.Text Ast = astFileParseInfo.Ast TypeCheckResults = astFileParseInfo.TypeCheckResults } - lintParsedFile optionalParams parsedFileInfo filePath + return lintParsedFile optionalParams parsedFileInfo filePath | ParseFile.Failed failure -> - LintResult.Failure (FailedToParseFile failure) + return LintResult.Failure (FailedToParseFile failure) else - LintResult.Failure (FailedToLoadFile filePath) + return LintResult.Failure (FailedToLoadFile filePath) + } - let results = filePaths |> Seq.map lintSingleFile |> Seq.toList + let! results = filePaths |> Seq.map lintSingleFile |> Async.Sequential - let failures = results |> List.choose (function | LintResult.Failure failure -> Some failure | _ -> None) - let warnings = results |> List.collect (function | LintResult.Success warning -> warning | _ -> List.empty) + let failures = + results + |> Seq.choose (function | LintResult.Failure failure -> Some failure | _ -> None) + |> Seq.toList + let warnings = + results + |> Seq.collect (function | LintResult.Success warning -> warning | _ -> List.empty) + |> Seq.toList match failures with - | firstFailure :: _ -> LintResult.Failure firstFailure - | [] -> LintResult.Success warnings + | firstFailure :: _ -> return LintResult.Failure firstFailure + | [] -> return LintResult.Success warnings | Error err -> - LintResult.Failure (RunTimeConfigError err) + return LintResult.Failure (RunTimeConfigError err) + } diff --git a/src/FSharpLint.Core/Application/Lint.fsi b/src/FSharpLint.Core/Application/Lint.fsi index 29ddd3332..04f09ba59 100644 --- a/src/FSharpLint.Core/Application/Lint.fsi +++ b/src/FSharpLint.Core/Application/Lint.fsi @@ -147,14 +147,11 @@ module Lint = val runLineRules : RunLineRulesConfig -> Suggestion.LintWarning [] /// Lints an entire F# solution by linting all projects specified in the `.sln`, `slnx` or `.slnf` file. - val lintSolution : optionalParams:OptionalLintParameters -> solutionFilePath:string -> toolsPath:Ionide.ProjInfo.Types.ToolsPath -> LintResult + val asyncLintSolution : optionalParams:OptionalLintParameters -> solutionFilePath:string -> toolsPath:Ionide.ProjInfo.Types.ToolsPath -> Async /// Lints an entire F# project by retrieving the files from a given /// path to the `.fsproj` file. - val lintProject : optionalParams:OptionalLintParameters -> projectFilePath:string -> toolsPath:Ionide.ProjInfo.Types.ToolsPath -> LintResult - - /// Lints F# source code. - val lintSource : optionalParams:OptionalLintParameters -> source:string -> LintResult + val asyncLintProject : optionalParams:OptionalLintParameters -> projectFilePath:string -> toolsPath:Ionide.ProjInfo.Types.ToolsPath -> Async /// Lints F# source code async. val asyncLintSource : optionalParams:OptionalLintParameters -> source:string -> Async @@ -164,10 +161,10 @@ module Lint = val lintParsedSource : optionalParams:OptionalLintParameters -> parsedFileInfo:ParsedFileInformation -> LintResult /// Lints an F# file from a given path to the `.fs` file. - val lintFile : optionalParams:OptionalLintParameters -> filePath:string -> LintResult + val asyncLintFile : optionalParams:OptionalLintParameters -> filePath:string -> Async /// Lints multiple F# files from given file paths. - val lintFiles : optionalParams:OptionalLintParameters -> filePaths:string seq -> LintResult + val asyncLintFiles : optionalParams:OptionalLintParameters -> filePaths:string seq -> Async /// Lints an F# file that has already been parsed using /// `FSharp.Compiler.Services` in the calling application. diff --git a/tests/FSharpLint.FunctionalTest/TestApi.fs b/tests/FSharpLint.FunctionalTest/TestApi.fs index d7fe3282b..52df66a24 100644 --- a/tests/FSharpLint.FunctionalTest/TestApi.fs +++ b/tests/FSharpLint.FunctionalTest/TestApi.fs @@ -64,7 +64,7 @@ module TestApi = let projectPath = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" let projectFile = projectPath "FSharpLint.FunctionalTest.TestedProject.NetCore.fsproj" - let result = lintProject OptionalLintParameters.Default projectFile toolsPath + let result = asyncLintProject OptionalLintParameters.Default projectFile toolsPath |> Async.RunSynchronously match result with | LintResult.Success warnings -> @@ -77,7 +77,7 @@ module TestApi = let projectPath = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" let projectFile = projectPath "FSharpLint.FunctionalTest.TestedProject.NetCore.fsproj" - let result = lintProject OptionalLintParameters.Default projectFile toolsPath + let result = asyncLintProject OptionalLintParameters.Default projectFile toolsPath |> Async.RunSynchronously match result with | LintResult.Success warnings -> @@ -92,7 +92,7 @@ module TestApi = let tempConfigFile = TestContext.CurrentContext.TestDirectory "fsharplint.json" File.WriteAllText (tempConfigFile, """{ "ignoreFiles": ["*"] }""") - let result = lintProject OptionalLintParameters.Default projectFile toolsPath + let result = asyncLintProject OptionalLintParameters.Default projectFile toolsPath |> Async.RunSynchronously File.Delete tempConfigFile match result with @@ -108,7 +108,7 @@ module TestApi = let projectPath = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" let solutionFile = projectPath solutionFileName - let result = lintSolution OptionalLintParameters.Default solutionFile toolsPath + let result = asyncLintSolution OptionalLintParameters.Default solutionFile toolsPath |> Async.RunSynchronously match result with | LintResult.Success warnings -> @@ -126,7 +126,9 @@ module TestApi = let relativePathToSolutionFile = Path.GetRelativePath (Directory.GetCurrentDirectory(), solutionFile) - let result = lintSolution OptionalLintParameters.Default relativePathToSolutionFile toolsPath + let result = + asyncLintSolution OptionalLintParameters.Default relativePathToSolutionFile toolsPath + |> Async.RunSynchronously match result with | LintResult.Success warnings -> From 5ae408620cb525e6f2a7e2d80da4465d434eb496 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Thu, 13 Nov 2025 11:16:25 +0100 Subject: [PATCH 12/22] Benchmarks: suppress rule Suppress NoAsyncRunSynchronouslyInLibrary rule. --- tests/FSharpLint.Benchmarks/Benchmark.fs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/FSharpLint.Benchmarks/Benchmark.fs b/tests/FSharpLint.Benchmarks/Benchmark.fs index 63f5e703c..41508d48c 100644 --- a/tests/FSharpLint.Benchmarks/Benchmark.fs +++ b/tests/FSharpLint.Benchmarks/Benchmark.fs @@ -8,6 +8,7 @@ open FSharpLint.Application.Lint open FSharpLint.Framework open FSharpLint.Framework.Utilities +// fsharplint:disable NoAsyncRunSynchronouslyInLibrary type Benchmark () = let generateAst source sourceFile = From 01d7f41be3ee454e7dd72a9f65b6898d31a9f5cf Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 10:29:26 +0100 Subject: [PATCH 13/22] NoAsyncRunSynchronouslyInLibrary: more excludes Exclude projects that have "console" in their name addition to those that contain "test". --- .../Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs index af41ae80b..14198e8f4 100644 --- a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs +++ b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs @@ -14,13 +14,17 @@ let hasEntryPoint (checkFileResults: FSharpCheckFileResults) = | Some implFile -> implFile.HasExplicitEntryPoint | None -> false +let excludedProjectNames = [ "test"; "console" ] + let isInTestProject (checkFileResults: FSharpCheckFileResults) = let namespaceIncludesTest = match checkFileResults.ImplementationFile with - | Some implFile -> implFile.QualifiedName.ToLowerInvariant().Contains "test" + | Some implFile -> + excludedProjectNames |> List.exists (fun name -> implFile.QualifiedName.ToLowerInvariant().Contains name) | None -> false let projectFileInfo = System.IO.FileInfo checkFileResults.ProjectContext.ProjectOptions.ProjectFileName - namespaceIncludesTest || projectFileInfo.Name.ToLowerInvariant().Contains "test" + namespaceIncludesTest + || excludedProjectNames |> List.exists (fun name -> projectFileInfo.Name.ToLowerInvariant().Contains name) let extractAttributeNames (attributes: SynAttributes) = seq { From 7b87a1a197082c8e54b48cc7dbb68350b19291be Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 11:08:41 +0100 Subject: [PATCH 14/22] docs: updated NoAsyncRunSynchronouslyInLibrary Updated docs for NoAsyncRunSynchronouslyInLibrary to make clear what code is considered to be library code. --- docs/content/how-tos/rules/FL0088.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/content/how-tos/rules/FL0088.md b/docs/content/how-tos/rules/FL0088.md index 1bff7a233..a8ee10eff 100644 --- a/docs/content/how-tos/rules/FL0088.md +++ b/docs/content/how-tos/rules/FL0088.md @@ -10,7 +10,12 @@ hide_menu: true ## Cause -`Async.RunSynchronously` method is used to run async computation in library code. +`Async.RunSynchronously` method is used to run async computation in library code. + +The rule assumes the code is in the library if none of the following is true: +- The code is inside NUnit or MSTest test. +- Namespace or project name contains "test" or "console". +- Assembly has `[]` attribute one one of the functions/methods. ## Rationale From 57e1b37a21cbcef20225bf8c7034db9c3064cc10 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 11:25:33 +0100 Subject: [PATCH 15/22] Core,Tests: include check project results When linting project. This will give more information for rules to use, such as all types defined in an assembly. --- src/FSharpLint.Core/Application/Lint.fs | 23 +++++++++++++++---- src/FSharpLint.Core/Application/Lint.fsi | 4 ++++ src/FSharpLint.Core/Framework/ParseFile.fs | 4 ++++ src/FSharpLint.Core/Framework/Rules.fs | 1 + tests/FSharpLint.Benchmarks/Benchmark.fs | 2 +- .../Rules/TestAstNodeRule.fs | 1 + .../Rules/TestHintMatcherBase.fs | 1 + .../Rules/TestIndentationRule.fs | 1 + .../Rules/TestLineRule.fs | 1 + .../Rules/TestNoTabCharactersRule.fs | 1 + tests/FSharpLint.FunctionalTest/TestApi.fs | 2 +- 11 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/FSharpLint.Core/Application/Lint.fs b/src/FSharpLint.Core/Application/Lint.fs index fb2cd884a..6a92582e2 100644 --- a/src/FSharpLint.Core/Application/Lint.fs +++ b/src/FSharpLint.Core/Application/Lint.fs @@ -124,6 +124,7 @@ module Lint = Rules: RuleMetadata[] GlobalConfig: Rules.GlobalRuleConfig TypeCheckResults: FSharpCheckFileResults option + ProjectCheckResults: FSharpCheckProjectResults option FilePath: string FileContent: string Lines: string[] @@ -147,6 +148,7 @@ module Lint = FileContent = config.FileContent Lines = config.Lines CheckInfo = config.TypeCheckResults + ProjectCheckInfo = config.ProjectCheckResults GlobalConfig = config.GlobalConfig } // Build state for rules with context. @@ -260,6 +262,7 @@ module Lint = Rules = enabledRules.AstNodeRules GlobalConfig = enabledRules.GlobalConfig TypeCheckResults = fileInfo.TypeCheckResults + ProjectCheckResults = fileInfo.ProjectCheckResults FilePath = fileInfo.File FileContent = fileInfo.Text Lines = lines @@ -414,6 +417,8 @@ module Lint = Source:string /// Optional results of inferring the types on the AST (allows for a more accurate lint). TypeCheckResults:FSharpCheckFileResults option + /// Optional results of project-wide type info (allows for a more accurate lint). + ProjectCheckResults:FSharpCheckProjectResults option } /// Gets a FSharpLint Configuration based on the provided ConfigurationParam. @@ -476,9 +481,14 @@ module Lint = let failedFiles = Array.choose getFailedFiles parsedFiles if Array.isEmpty failedFiles then + let! projectCheckResults = checker.ParseAndCheckProject projectOptions + parsedFiles |> Array.choose getParsedFiles - |> Array.iter (lint lintInformation) + |> Array.iter (fun fileParseResult -> + lint + lintInformation + { fileParseResult with ProjectCheckResults = Some projectCheckResults }) return Success () else @@ -576,6 +586,7 @@ module Lint = { ParseFile.Text = parsedFileInfo.Source ParseFile.Ast = parsedFileInfo.Ast ParseFile.TypeCheckResults = parsedFileInfo.TypeCheckResults + ParseFile.ProjectCheckResults = parsedFileInfo.ProjectCheckResults ParseFile.File = "" } lint lintInformation parsedFileInfo @@ -594,7 +605,8 @@ module Lint = let parsedFileInfo = { Source = parseFileInformation.Text Ast = parseFileInformation.Ast - TypeCheckResults = parseFileInformation.TypeCheckResults } + TypeCheckResults = parseFileInformation.TypeCheckResults + ProjectCheckResults = None } return lintParsedSource optionalParams parsedFileInfo | ParseFile.Failed failure -> return LintResult.Failure(FailedToParseFile failure) @@ -621,6 +633,7 @@ module Lint = { ParseFile.Text = parsedFileInfo.Source ParseFile.Ast = parsedFileInfo.Ast ParseFile.TypeCheckResults = parsedFileInfo.TypeCheckResults + ParseFile.ProjectCheckResults = parsedFileInfo.ProjectCheckResults ParseFile.File = filePath } lint lintInformation parsedFileInfo @@ -638,7 +651,8 @@ module Lint = let parsedFileInfo = { Source = astFileParseInfo.Text Ast = astFileParseInfo.Ast - TypeCheckResults = astFileParseInfo.TypeCheckResults } + TypeCheckResults = astFileParseInfo.TypeCheckResults + ProjectCheckResults = astFileParseInfo.ProjectCheckResults } return lintParsedFile optionalParams parsedFileInfo filePath | ParseFile.Failed failure -> return LintResult.Failure(FailedToParseFile failure) @@ -661,7 +675,8 @@ module Lint = let parsedFileInfo = { Source = astFileParseInfo.Text Ast = astFileParseInfo.Ast - TypeCheckResults = astFileParseInfo.TypeCheckResults } + TypeCheckResults = astFileParseInfo.TypeCheckResults + ProjectCheckResults = astFileParseInfo.ProjectCheckResults } return lintParsedFile optionalParams parsedFileInfo filePath | ParseFile.Failed failure -> return LintResult.Failure (FailedToParseFile failure) diff --git a/src/FSharpLint.Core/Application/Lint.fsi b/src/FSharpLint.Core/Application/Lint.fsi index 04f09ba59..46caf500d 100644 --- a/src/FSharpLint.Core/Application/Lint.fsi +++ b/src/FSharpLint.Core/Application/Lint.fsi @@ -74,6 +74,9 @@ module Lint = /// Optional results of inferring the types on the AST (allows for a more accurate lint). TypeCheckResults: FSharpCheckFileResults option + + /// Optional results of project-wide type info (allows for a more accurate lint). + ProjectCheckResults:FSharpCheckProjectResults option } type BuildFailure = | InvalidProjectFileMessage of string @@ -124,6 +127,7 @@ module Lint = Rules: RuleMetadata[] GlobalConfig: Rules.GlobalRuleConfig TypeCheckResults: FSharpCheckFileResults option + ProjectCheckResults: FSharpCheckProjectResults option FilePath: string FileContent: string Lines: string[] diff --git a/src/FSharpLint.Core/Framework/ParseFile.fs b/src/FSharpLint.Core/Framework/ParseFile.fs index 6a5edcb56..012622881 100644 --- a/src/FSharpLint.Core/Framework/ParseFile.fs +++ b/src/FSharpLint.Core/Framework/ParseFile.fs @@ -23,6 +23,9 @@ module ParseFile = /// Optional results of inferring the types on the AST (allows for a more accurate lint). TypeCheckResults:FSharpCheckFileResults option + /// Optional results of project-wide type info (allows for a more accurate lint). + ProjectCheckResults:FSharpCheckProjectResults option + /// Path to the file. File:string } @@ -49,6 +52,7 @@ module ParseFile = Text = source Ast = parseResults.ParseTree TypeCheckResults = Some(typeCheckResults) + ProjectCheckResults = None File = file } | FSharpCheckFileAnswer.Aborted -> return Failed(AbortedTypeCheck) diff --git a/src/FSharpLint.Core/Framework/Rules.fs b/src/FSharpLint.Core/Framework/Rules.fs index 2877244ab..29057abe8 100644 --- a/src/FSharpLint.Core/Framework/Rules.fs +++ b/src/FSharpLint.Core/Framework/Rules.fs @@ -30,6 +30,7 @@ type AstNodeRuleParams = FileContent:string Lines:string [] CheckInfo:FSharpCheckFileResults option + ProjectCheckInfo:FSharpCheckProjectResults option GlobalConfig:GlobalRuleConfig } type LineRuleParams = diff --git a/tests/FSharpLint.Benchmarks/Benchmark.fs b/tests/FSharpLint.Benchmarks/Benchmark.fs index 41508d48c..a240a5d60 100644 --- a/tests/FSharpLint.Benchmarks/Benchmark.fs +++ b/tests/FSharpLint.Benchmarks/Benchmark.fs @@ -31,7 +31,7 @@ type Benchmark () = let (fileInfo, lines) = let text = File.ReadAllText sourceFile let tree = generateAst text sourceFile - ({ Ast = tree; Source = text; TypeCheckResults = None }, String.toLines text |> Array.toList) + ({ Ast = tree; Source = text; TypeCheckResults = None; ProjectCheckResults = None }, String.toLines text |> Array.toList) [] member this.LintParsedFile () = diff --git a/tests/FSharpLint.Core.Tests/Rules/TestAstNodeRule.fs b/tests/FSharpLint.Core.Tests/Rules/TestAstNodeRule.fs index 0f78b6f19..425a3e5bd 100644 --- a/tests/FSharpLint.Core.Tests/Rules/TestAstNodeRule.fs +++ b/tests/FSharpLint.Core.Tests/Rules/TestAstNodeRule.fs @@ -42,6 +42,7 @@ type TestAstNodeRuleBase (rule:Rule) = Rules = Array.singleton rule GlobalConfig = globalConfig TypeCheckResults = checkResult + ProjectCheckResults = None FilePath = (Option.defaultValue String.Empty fileName) FileContent = input Lines = (input.Split("\n")) diff --git a/tests/FSharpLint.Core.Tests/Rules/TestHintMatcherBase.fs b/tests/FSharpLint.Core.Tests/Rules/TestHintMatcherBase.fs index 5f2222309..6fe9dbe11 100644 --- a/tests/FSharpLint.Core.Tests/Rules/TestHintMatcherBase.fs +++ b/tests/FSharpLint.Core.Tests/Rules/TestHintMatcherBase.fs @@ -64,6 +64,7 @@ type TestHintMatcherBase () = Rules = Array.singleton rule GlobalConfig = globalConfig TypeCheckResults = checkResult + ProjectCheckResults = None FilePath = (Option.defaultValue String.Empty fileName) FileContent = input Lines = (input.Split("\n")) diff --git a/tests/FSharpLint.Core.Tests/Rules/TestIndentationRule.fs b/tests/FSharpLint.Core.Tests/Rules/TestIndentationRule.fs index 8abd41702..e2d2fd68b 100644 --- a/tests/FSharpLint.Core.Tests/Rules/TestIndentationRule.fs +++ b/tests/FSharpLint.Core.Tests/Rules/TestIndentationRule.fs @@ -37,6 +37,7 @@ type TestIndentationRuleBase (rule:Rule) = Rules = Array.empty GlobalConfig = globalConfig TypeCheckResults = None + ProjectCheckResults = None FilePath = fileName FileContent = input Lines = lines diff --git a/tests/FSharpLint.Core.Tests/Rules/TestLineRule.fs b/tests/FSharpLint.Core.Tests/Rules/TestLineRule.fs index c9c46cf02..480e55f82 100644 --- a/tests/FSharpLint.Core.Tests/Rules/TestLineRule.fs +++ b/tests/FSharpLint.Core.Tests/Rules/TestLineRule.fs @@ -37,6 +37,7 @@ type TestLineRuleBase (rule:Rule) = Rules = Array.empty GlobalConfig = globalConfig TypeCheckResults = None + ProjectCheckResults = None FilePath = fileName FileContent = input Lines = lines diff --git a/tests/FSharpLint.Core.Tests/Rules/TestNoTabCharactersRule.fs b/tests/FSharpLint.Core.Tests/Rules/TestNoTabCharactersRule.fs index 2807d7ad8..ccfcaccf2 100644 --- a/tests/FSharpLint.Core.Tests/Rules/TestNoTabCharactersRule.fs +++ b/tests/FSharpLint.Core.Tests/Rules/TestNoTabCharactersRule.fs @@ -37,6 +37,7 @@ type TestNoTabCharactersRuleBase (rule:Rule) = Rules = Array.empty GlobalConfig = globalConfig TypeCheckResults = None + ProjectCheckResults = None FilePath = fileName FileContent = input Lines = lines diff --git a/tests/FSharpLint.FunctionalTest/TestApi.fs b/tests/FSharpLint.FunctionalTest/TestApi.fs index 52df66a24..635113f10 100644 --- a/tests/FSharpLint.FunctionalTest/TestApi.fs +++ b/tests/FSharpLint.FunctionalTest/TestApi.fs @@ -38,7 +38,7 @@ module TestApi = member _.``Performance of linting an existing file``() = let text = File.ReadAllText sourceFile let tree = generateAst text - let fileInfo = { Ast = tree; Source = text; TypeCheckResults = None } + let fileInfo = { Ast = tree; Source = text; TypeCheckResults = None; ProjectCheckResults = None } let stopwatch = Stopwatch.StartNew() let times = ResizeArray() From e4516629b2fb76ce09ef5371ab3c6bbd5d44f13c Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 09:56:31 +0100 Subject: [PATCH 16/22] NoAsyncRunSynchronouslyInLibrary: check assembly For EntryPoint point attribute instead of just current file. --- .../NoAsyncRunSynchronouslyInLibrary.fs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs index 14198e8f4..7c9cb62f6 100644 --- a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs +++ b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs @@ -9,9 +9,18 @@ open FSharpLint.Framework.Ast open FSharpLint.Framework.Rules open FSharpLint.Framework.Utilities -let hasEntryPoint (checkFileResults: FSharpCheckFileResults) = - match checkFileResults.ImplementationFile with - | Some implFile -> implFile.HasExplicitEntryPoint +let hasEntryPoint (checkFileResults: FSharpCheckFileResults) (maybeProjectCheckResults: FSharpCheckProjectResults option) = + let hasEntryPointInTheSameFile = + match checkFileResults.ImplementationFile with + | Some implFile -> implFile.HasExplicitEntryPoint + | None -> false + + hasEntryPointInTheSameFile + || + match maybeProjectCheckResults with + | Some projectCheckResults -> + projectCheckResults.AssemblyContents.ImplementationFiles + |> Seq.exists (fun implFile -> implFile.HasExplicitEntryPoint) | None -> false let excludedProjectNames = [ "test"; "console" ] @@ -56,7 +65,7 @@ let checkIfInLibrary (args: AstNodeRuleParams) (range: range) : array - hasEntryPoint checkFileResults || isInTestProject checkFileResults || isInsideTest (args.GetParents args.NodeIndex) + hasEntryPoint checkFileResults args.ProjectCheckInfo || isInTestProject checkFileResults || isInsideTest (args.GetParents args.NodeIndex) | None -> isInsideTest (args.GetParents args.NodeIndex) From db25b910024dba4e0577058d3a8529f7bfc4a3cb Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 12:05:45 +0100 Subject: [PATCH 17/22] Revert "Benchmarks: suppress rule" This reverts commit 7ba5ca4ff954b5e63121626e301d704597412fb2. Suppression is no longer needed because Benchmarks project has `[]` attribute which is now properly recognized. --- tests/FSharpLint.Benchmarks/Benchmark.fs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/FSharpLint.Benchmarks/Benchmark.fs b/tests/FSharpLint.Benchmarks/Benchmark.fs index a240a5d60..4e8b536ef 100644 --- a/tests/FSharpLint.Benchmarks/Benchmark.fs +++ b/tests/FSharpLint.Benchmarks/Benchmark.fs @@ -8,7 +8,6 @@ open FSharpLint.Application.Lint open FSharpLint.Framework open FSharpLint.Framework.Utilities -// fsharplint:disable NoAsyncRunSynchronouslyInLibrary type Benchmark () = let generateAst source sourceFile = From de85e8dcd6520936d52d6cdc96edf7b5e5cd275a Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 12:26:35 +0100 Subject: [PATCH 18/22] NoAsyncRunSynchronouslyInLibrary: add 2 tests Added 2 more tests for NoAsyncRunSynchronouslyInLibrary rule. --- .../NoAsyncRunSynchronouslyInLibrary.fs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs index ecb04dc0b..18bf235f7 100644 --- a/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs +++ b/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs @@ -34,6 +34,23 @@ let main () = this.AssertNoWarnings() + [] + member this.``Async.RunSynchronously may be used in code module that has function with entry point``() = + this.Parse(""" +module Program + +let foo () = + async { + return () + } + |> Async.RunSynchronously + +[] +let main () = + 0""") + + this.AssertNoWarnings() + [] member this.``Async.RunSynchronously may be used in NUnit test code``() = this.Parse(""" @@ -65,3 +82,22 @@ type FooTest () = |> Async.RunSynchronously""") this.AssertNoWarnings() + + [] + member this.``Async.RunSynchronously may be used in module with tests``() = + this.Parse(""" +module Program + +let foo () = + async { + return () + } + |> Async.RunSynchronously + +[] +type FooTest () = + [] + member this.Foo() = + ()""") + + this.AssertNoWarnings() From 25099aa09181b9843305e9973d542021b3659b9c Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 12:37:47 +0100 Subject: [PATCH 19/22] NoAsyncRunSynchronouslyInLibrary: fix rule Make rule pass new tests by checking all nodes in the file for test attributes, not only parent nodes. Also when checking a project, check all files in the project for classes that are marked by test attributes. --- .../NoAsyncRunSynchronouslyInLibrary.fs | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs index 7c9cb62f6..60a57057e 100644 --- a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs +++ b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs @@ -1,6 +1,7 @@ module FSharpLint.Rules.NoAsyncRunSynchronouslyInLibrary open FSharp.Compiler.Syntax +open FSharp.Compiler.Symbols open FSharp.Compiler.Text open FSharp.Compiler.CodeAnalysis open FSharpLint.Framework @@ -46,7 +47,7 @@ let extractAttributeNames (attributes: SynAttributes) = let testMethodAttributes = [ "Test"; "TestMethod" ] let testClassAttributes = [ "TestFixture"; "TestClass" ] -let isInsideTest (parents: list) = +let isInTheSameModuleAsTest (nodes: array) (maybeProjectCheckResults: FSharpCheckProjectResults option) = let isTestMethodOrClass node = match node with | AstNode.MemberDefinition(SynMemberDefn.Member(SynBinding(_, _, _, _, attributes, _, _, _, _, _, _, _, _), _)) -> @@ -59,15 +60,32 @@ let isInsideTest (parents: list) = |> Seq.exists (fun name -> testClassAttributes |> List.contains name) | _ -> false - parents |> List.exists isTestMethodOrClass + let isDeclarationOfTestClass declaration = + match declaration with + | FSharpImplementationFileDeclaration.Entity(entity, _) -> + entity.Attributes + |> Seq.exists (fun attr -> testClassAttributes |> List.contains attr.AttributeType.DisplayName) + | _ -> false + + match maybeProjectCheckResults with + | Some projectCheckResults -> + projectCheckResults.AssemblyContents.ImplementationFiles + |> Seq.exists (fun implFile -> + implFile.Declarations + |> Seq.exists isDeclarationOfTestClass + ) + | None -> + nodes |> Array.exists (fun node -> isTestMethodOrClass node.Actual) let checkIfInLibrary (args: AstNodeRuleParams) (range: range) : array = let ruleNotApplicable = match args.CheckInfo with | Some checkFileResults -> - hasEntryPoint checkFileResults args.ProjectCheckInfo || isInTestProject checkFileResults || isInsideTest (args.GetParents args.NodeIndex) + hasEntryPoint checkFileResults args.ProjectCheckInfo + || isInTestProject checkFileResults + || isInTheSameModuleAsTest args.SyntaxArray args.ProjectCheckInfo | None -> - isInsideTest (args.GetParents args.NodeIndex) + isInTheSameModuleAsTest args.SyntaxArray args.ProjectCheckInfo if ruleNotApplicable then Array.empty From 128852769e6091b4bd09332b15b9d4c941294823 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 14:04:52 +0100 Subject: [PATCH 20/22] Core: reintroduce non-async parsing methods That were removed in commit 9de63ea ("Core,Console,Tests: refactoring"). --- src/FSharpLint.Core/Application/Lint.fs | 15 +++++++++++++++ src/FSharpLint.Core/Application/Lint.fsi | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/FSharpLint.Core/Application/Lint.fs b/src/FSharpLint.Core/Application/Lint.fs index 6a92582e2..2cffa503e 100644 --- a/src/FSharpLint.Core/Application/Lint.fs +++ b/src/FSharpLint.Core/Application/Lint.fs @@ -510,6 +510,9 @@ module Lint = return FailedToLoadFile projectFilePath |> LintResult.Failure } + let lintProject optionalParams projectFilePath toolsPath = + asyncLintProject optionalParams projectFilePath toolsPath |> Async.RunSynchronously + /// Lints an entire F# solution by linting all projects specified in the `.sln`, `slnx` or `.slnf` file. let asyncLintSolution (optionalParams:OptionalLintParameters) (solutionFilePath:string) (toolsPath:Ionide.ProjInfo.Types.ToolsPath) = async { if IO.File.Exists solutionFilePath then @@ -566,6 +569,9 @@ module Lint = return FailedToLoadFile solutionFilePath |> LintResult.Failure } + let lintSolution optionalParams solutionFilePath toolsPath = + asyncLintSolution optionalParams solutionFilePath toolsPath |> Async.RunSynchronously + /// Lints F# source code that has already been parsed using `FSharp.Compiler.Services` in the calling application. let lintParsedSource optionalParams parsedFileInfo = match getConfig optionalParams.Configuration with @@ -611,6 +617,9 @@ module Lint = return lintParsedSource optionalParams parsedFileInfo | ParseFile.Failed failure -> return LintResult.Failure(FailedToParseFile failure) } + + let lintSource optionalParams source = + asyncLintSource optionalParams source |> Async.RunSynchronously /// Lints an F# file that has already been parsed using `FSharp.Compiler.Services` in the calling application. let lintParsedFile (optionalParams:OptionalLintParameters) (parsedFileInfo:ParsedFileInformation) (filePath:string) = @@ -660,6 +669,9 @@ module Lint = return FailedToLoadFile filePath |> LintResult.Failure } + let lintFile optionalParams filePath = + asyncLintFile optionalParams filePath |> Async.RunSynchronously + /// Lints multiple F# files from given file paths. let asyncLintFiles optionalParams filePaths = async { let checker = FSharpChecker.Create(keepAssemblyContents=true) @@ -701,3 +713,6 @@ module Lint = | Error err -> return LintResult.Failure (RunTimeConfigError err) } + + let lintFiles optionalParams filePaths = + asyncLintFiles optionalParams filePaths |> Async.RunSynchronously diff --git a/src/FSharpLint.Core/Application/Lint.fsi b/src/FSharpLint.Core/Application/Lint.fsi index 46caf500d..618d48317 100644 --- a/src/FSharpLint.Core/Application/Lint.fsi +++ b/src/FSharpLint.Core/Application/Lint.fsi @@ -153,13 +153,22 @@ module Lint = /// Lints an entire F# solution by linting all projects specified in the `.sln`, `slnx` or `.slnf` file. val asyncLintSolution : optionalParams:OptionalLintParameters -> solutionFilePath:string -> toolsPath:Ionide.ProjInfo.Types.ToolsPath -> Async + /// [Obsolete] Lints an entire F# solution by linting all projects specified in the `.sln`, `slnx` or `.slnf` file. + val lintSolution : optionalParams:OptionalLintParameters -> solutionFilePath:string -> toolsPath:Ionide.ProjInfo.Types.ToolsPath -> LintResult + /// Lints an entire F# project by retrieving the files from a given /// path to the `.fsproj` file. val asyncLintProject : optionalParams:OptionalLintParameters -> projectFilePath:string -> toolsPath:Ionide.ProjInfo.Types.ToolsPath -> Async + /// [Obsolete] Lints an entire F# project by retrieving the files from a given path to the `.fsproj` file. + val lintProject : optionalParams:OptionalLintParameters -> projectFilePath:string -> toolsPath:Ionide.ProjInfo.Types.ToolsPath -> LintResult + /// Lints F# source code async. val asyncLintSource : optionalParams:OptionalLintParameters -> source:string -> Async + /// [Obsolete] Lints F# source code. + val lintSource : optionalParams:OptionalLintParameters -> source:string -> LintResult + /// Lints F# source code that has already been parsed using /// `FSharp.Compiler.Services` in the calling application. val lintParsedSource : optionalParams:OptionalLintParameters -> parsedFileInfo:ParsedFileInformation -> LintResult @@ -167,9 +176,15 @@ module Lint = /// Lints an F# file from a given path to the `.fs` file. val asyncLintFile : optionalParams:OptionalLintParameters -> filePath:string -> Async + /// [Obsolete] Lints an F# file from a given path to the `.fs` file. + val lintFile : optionalParams:OptionalLintParameters -> filePath:string -> LintResult + /// Lints multiple F# files from given file paths. val asyncLintFiles : optionalParams:OptionalLintParameters -> filePaths:string seq -> Async + /// [Obsolete] Lints multiple F# files from given file paths. + val lintFiles : optionalParams:OptionalLintParameters -> filePaths:string seq -> LintResult + /// Lints an F# file that has already been parsed using /// `FSharp.Compiler.Services` in the calling application. val lintParsedFile : optionalParams:OptionalLintParameters -> parsedFileInfo:ParsedFileInformation -> filePath:string -> LintResult From 9e628a3b3fc5ff3be71db974f891d5b4c9e2c345 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Wed, 19 Nov 2025 09:27:25 +0100 Subject: [PATCH 21/22] NoAsyncRunSynchronouslyInLibrary: ignore obsolete Methods and functions when applying the rule. Added 2 more tests. Marked non-async parsing methods re-introduced in previous commit with `[]` attribute. --- src/FSharpLint.Core/Application/Lint.fs | 5 ++++ .../NoAsyncRunSynchronouslyInLibrary.fs | 19 +++++++++++- .../NoAsyncRunSynchronouslyInLibrary.fs | 29 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/FSharpLint.Core/Application/Lint.fs b/src/FSharpLint.Core/Application/Lint.fs index 2cffa503e..eca7b1fd3 100644 --- a/src/FSharpLint.Core/Application/Lint.fs +++ b/src/FSharpLint.Core/Application/Lint.fs @@ -510,6 +510,7 @@ module Lint = return FailedToLoadFile projectFilePath |> LintResult.Failure } + [] let lintProject optionalParams projectFilePath toolsPath = asyncLintProject optionalParams projectFilePath toolsPath |> Async.RunSynchronously @@ -569,6 +570,7 @@ module Lint = return FailedToLoadFile solutionFilePath |> LintResult.Failure } + [] let lintSolution optionalParams solutionFilePath toolsPath = asyncLintSolution optionalParams solutionFilePath toolsPath |> Async.RunSynchronously @@ -618,6 +620,7 @@ module Lint = | ParseFile.Failed failure -> return LintResult.Failure(FailedToParseFile failure) } + [] let lintSource optionalParams source = asyncLintSource optionalParams source |> Async.RunSynchronously @@ -669,6 +672,7 @@ module Lint = return FailedToLoadFile filePath |> LintResult.Failure } + [] let lintFile optionalParams filePath = asyncLintFile optionalParams filePath |> Async.RunSynchronously @@ -714,5 +718,6 @@ module Lint = return LintResult.Failure (RunTimeConfigError err) } + [] let lintFiles optionalParams filePaths = asyncLintFiles optionalParams filePaths |> Async.RunSynchronously diff --git a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs index 60a57057e..7c64fc582 100644 --- a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs +++ b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs @@ -77,15 +77,32 @@ let isInTheSameModuleAsTest (nodes: array) (maybeProje | None -> nodes |> Array.exists (fun node -> isTestMethodOrClass node.Actual) +let isInObsoleteMethodOrFunction parents = + let isObsolete node = + match node with + | AstNode.MemberDefinition(SynMemberDefn.Member(SynBinding(_, _, _, _, attributes, _, _, _, _, _, _, _, _), _)) -> + attributes + |> extractAttributeNames + |> Seq.contains "Obsolete" + | AstNode.Binding(SynBinding(_, _, _, _, attributes, _, _, _, _, _, _, _, _)) -> + attributes + |> extractAttributeNames + |> Seq.contains "Obsolete" + | _ -> false + + parents |> List.exists isObsolete + let checkIfInLibrary (args: AstNodeRuleParams) (range: range) : array = let ruleNotApplicable = match args.CheckInfo with | Some checkFileResults -> hasEntryPoint checkFileResults args.ProjectCheckInfo || isInTestProject checkFileResults + || isInObsoleteMethodOrFunction (args.GetParents args.NodeIndex) || isInTheSameModuleAsTest args.SyntaxArray args.ProjectCheckInfo | None -> - isInTheSameModuleAsTest args.SyntaxArray args.ProjectCheckInfo + isInObsoleteMethodOrFunction (args.GetParents args.NodeIndex) + || isInTheSameModuleAsTest args.SyntaxArray args.ProjectCheckInfo if ruleNotApplicable then Array.empty diff --git a/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs index 18bf235f7..03e6f67c2 100644 --- a/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs +++ b/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs @@ -101,3 +101,32 @@ type FooTest () = ()""") this.AssertNoWarnings() + + [] + member this.``Async.RunSynchronously may be used in methods with Obsolete attribute``() = + this.Parse(""" +module Program + +type FooTest () = + [] + member this.Foo() = + async { + return () + } + |> Async.RunSynchronously""") + + this.AssertNoWarnings() + + [] + member this.``Async.RunSynchronously may be used in functions with Obsolete attribute``() = + this.Parse(""" +module Program + +[] +let Foo() = + async { + return () + } + |> Async.RunSynchronously""") + + this.AssertNoWarnings() From a6e889a8150f45e4d903936c81ee699195c8d22c Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Wed, 19 Nov 2025 11:59:01 +0100 Subject: [PATCH 22/22] NoAsyncRunSynchronouslyInLibrary: updated docs To address feedback. --- docs/content/how-tos/rules/FL0088.md | 38 ++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/content/how-tos/rules/FL0088.md b/docs/content/how-tos/rules/FL0088.md index a8ee10eff..6df26ac1e 100644 --- a/docs/content/how-tos/rules/FL0088.md +++ b/docs/content/how-tos/rules/FL0088.md @@ -6,7 +6,7 @@ hide_menu: true # NoAsyncRunSynchronouslyInLibrary (FL0088) -*Introduced in `0.26.7`* +*Introduced in `0.26.8`* ## Cause @@ -19,12 +19,46 @@ The rule assumes the code is in the library if none of the following is true: ## Rationale -Using `Async.RunSynchronously` outside of scripts and tests can lead to program becoming non-responsive. +Using `Async.RunSynchronously` outside of scripts, tests, and console projects can lead to program becoming non-responsive. ## How To Fix Remove `Async.RunSynchronously` and wrap the code that uses `async` computations in `async` computation, using `let!`, `use!`, `match!`, or `return!` keyword to get the result. +Example: + +```fsharp +type SomeType() = + member self.SomeMethod someParam = + let foo = + asyncSomeFunc someParam + |> Async.RunSynchronously + processFoo foo +``` + +The function can be modified to be asynchronous. In that case it might be better to prefix its name with Async: + +```fsharp +type SomeType() = + member self.AsyncSomeMethod someParam = async { + let! foo = asyncSomeFunc someParam + return processFoo foo + } +``` + +In case the method/function is public, a nice C#-friendly overload that returns `Task<'T>` could be provided, suffixed with Async, that just calls the previous method with `Async.StartAsTask`: + +```fsharp +type SomeType() = + member self.AsyncSomeMethod someParam = async { + let! foo = asyncSomeFunc someParam + return processFoo foo + } + member self.SomeMethodsync someParam = + self.AsyncSomeMethod someParam + |> Async.StartAsTask +``` + ## Rule Settings {