From 37ea50e0ee149e9ef7d83f49530aa5856eba6abe Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 6 Jan 2026 10:32:24 +0100 Subject: [PATCH 01/23] 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 daa002b30..867a4ce37 100644 --- a/src/FSharpLint.Core/FSharpLint.Core.fsproj +++ b/src/FSharpLint.Core/FSharpLint.Core.fsproj @@ -70,6 +70,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 910e968d6..01889c1bc 100644 --- a/src/FSharpLint.Core/Rules/Identifiers.fs +++ b/src/FSharpLint.Core/Rules/Identifiers.fs @@ -94,3 +94,4 @@ let FavourAsKeyword = identifier 86 let InterpolatedStringWithNoSubstitution = identifier 87 let IndexerAccessorStyleConsistency = identifier 88 let FavourSingleton = identifier 89 +let NoAsyncRunSynchronouslyInLibrary = identifier 90 diff --git a/tests/FSharpLint.Core.Tests/FSharpLint.Core.Tests.fsproj b/tests/FSharpLint.Core.Tests/FSharpLint.Core.Tests.fsproj index b376875d1..07b067b11 100644 --- a/tests/FSharpLint.Core.Tests/FSharpLint.Core.Tests.fsproj +++ b/tests/FSharpLint.Core.Tests/FSharpLint.Core.Tests.fsproj @@ -49,6 +49,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 69dc2d9a57612ac0ff5860e13332bd74210691be Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 4 Nov 2025 12:32:31 +0100 Subject: [PATCH 02/23] 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 92b713b3b..a6f38a3f4 100644 --- a/src/FSharpLint.Core/Text.resx +++ b/src/FSharpLint.Core/Text.resx @@ -390,4 +390,7 @@ Consider using List.singleton/Array.singleton instead of single member list/array. + + Async.RunSynchronously should not be used in libraries. + From 313aaec19ca2e4f68ca227cb99332ee0b5737a16 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 4 Nov 2025 14:07:29 +0100 Subject: [PATCH 03/23] 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 d84c0e9331d4af1665a917294d091ada4042e25a Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Wed, 5 Nov 2025 09:51:50 +0100 Subject: [PATCH 04/23] 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 bc406ba6dff2a0d9ebeca25e66d3277cb1cb2b66 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Wed, 5 Nov 2025 12:35:27 +0100 Subject: [PATCH 05/23] 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 4179957c45681bda3b0b4b1f13b563f124694270 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Wed, 5 Nov 2025 13:21:08 +0100 Subject: [PATCH 06/23] 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 0d2efba81..f02086d1e 100644 --- a/src/FSharpLint.Core/Application/Configuration.fs +++ b/src/FSharpLint.Core/Application/Configuration.fs @@ -554,7 +554,8 @@ type Configuration = EnsureTailCallDiagnosticsInRecursiveFunctions:EnabledConfig option FavourAsKeyword:EnabledConfig option InterpolatedStringWithNoSubstitution:EnabledConfig option - FavourSingleton:EnabledConfig option } + FavourSingleton:EnabledConfig option + NoAsyncRunSynchronouslyInLibrary:EnabledConfig option} with static member Zero = { Global = None @@ -656,6 +657,7 @@ with FavourAsKeyword = None InterpolatedStringWithNoSubstitution = None FavourSingleton = None + NoAsyncRunSynchronouslyInLibrary = None } // fsharplint:enable RecordFieldNames @@ -859,6 +861,7 @@ let flattenConfig (config:Configuration) = config.FavourAsKeyword |> Option.bind (constructRuleIfEnabled FavourAsKeyword.rule) config.InterpolatedStringWithNoSubstitution |> Option.bind (constructRuleIfEnabled InterpolatedStringWithNoSubstitution.rule) config.FavourSingleton |> Option.bind (constructRuleIfEnabled FavourSingleton.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 3f7e1c858..7b32a6b17 100644 --- a/src/FSharpLint.Core/fsharplint.json +++ b/src/FSharpLint.Core/fsharplint.json @@ -341,6 +341,7 @@ } }, "favourSingleton": { "enabled": false }, + "noAsyncRunSynchronouslyInLibrary": { "enabled": true }, "hints": { "add": [ "not (a = b) ===> a <> b", From eb6e5a9c7e4fa8dc6dfc836b5ed96c9e372a30f5 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Wed, 5 Nov 2025 13:56:43 +0100 Subject: [PATCH 07/23] 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 33502fbf0286a9420183598914fdbcb1e03dead5 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Wed, 5 Nov 2025 14:12:15 +0100 Subject: [PATCH 08/23] 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 3d773728382e9883a331f350f7d0cac4428ab437 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Thu, 6 Nov 2025 11:47:42 +0100 Subject: [PATCH 09/23] 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 1c178bd8de968ef9500366153183a3cdc9692e1c Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 10:08:08 +0100 Subject: [PATCH 10/23] docs: NoAsyncRunSynchronouslyInLibrary rule docs --- docs/content/how-tos/rule-configuration.md | 1 + docs/content/how-tos/rules/FL0090.md | 29 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 docs/content/how-tos/rules/FL0090.md diff --git a/docs/content/how-tos/rule-configuration.md b/docs/content/how-tos/rule-configuration.md index b0af74e66..7870db243 100644 --- a/docs/content/how-tos/rule-configuration.md +++ b/docs/content/how-tos/rule-configuration.md @@ -130,3 +130,4 @@ The following rules can be specified for linting. - [InterpolatedStringWithNoSubstitution (FL0087)](rules/FL0087.html) - [IndexerAccessorStyleConsistency (FL0088)](rules/FL0088.html) - [FavourSingleton (FL0089)](rules/FL0089.html) +- [NoAsyncRunSynchronouslyInLibrary (FL0090)](rules/FL0090.html) diff --git a/docs/content/how-tos/rules/FL0090.md b/docs/content/how-tos/rules/FL0090.md new file mode 100644 index 000000000..fcaf2618b --- /dev/null +++ b/docs/content/how-tos/rules/FL0090.md @@ -0,0 +1,29 @@ +--- +title: FL0090 +category: how-to +hide_menu: true +--- + +# NoAsyncRunSynchronouslyInLibrary (FL0090) + +*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 11da8244951bcc5628b3471accd522ded615c9dd Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Wed, 12 Nov 2025 13:50:40 +0100 Subject: [PATCH 11/23] 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 df33fa137..d62691ed3 100644 --- a/src/FSharpLint.Console/Program.fs +++ b/src/FSharpLint.Console/Program.fs @@ -158,9 +158,9 @@ let private lint 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 @@ -169,9 +169,9 @@ let private lint 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 df906fdcc630a4018fbeb14d0a64e773bb57b984 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 10:29:26 +0100 Subject: [PATCH 12/23] 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 b2200cfeb55115ca488c1d056291603384f4ea38 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 11:08:41 +0100 Subject: [PATCH 13/23] docs: updated NoAsyncRunSynchronouslyInLibrary Updated docs for NoAsyncRunSynchronouslyInLibrary to make clear what code is considered to be library code. --- docs/content/how-tos/rules/FL0090.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/content/how-tos/rules/FL0090.md b/docs/content/how-tos/rules/FL0090.md index fcaf2618b..372f17028 100644 --- a/docs/content/how-tos/rules/FL0090.md +++ b/docs/content/how-tos/rules/FL0090.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 916861fe5b2e2f8967844716b9fbf7786ffd9d52 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 11:25:33 +0100 Subject: [PATCH 14/23] 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 b8e844401..86b0586f7 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 63f5e703c..4e8b536ef 100644 --- a/tests/FSharpLint.Benchmarks/Benchmark.fs +++ b/tests/FSharpLint.Benchmarks/Benchmark.fs @@ -30,7 +30,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 3f5cc902d4fb0e798366f81e831383000e3dd35f Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 09:56:31 +0100 Subject: [PATCH 15/23] 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 f1fb44420922d31da45cdab1461334e84fd49fb0 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 12:26:35 +0100 Subject: [PATCH 16/23] 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 2de968cd489f98114071ac1de99917b5e35efd7a Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 12:37:47 +0100 Subject: [PATCH 17/23] 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 574c0e7096f3d066f77e237cc209c8166a384d48 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 18 Nov 2025 14:04:52 +0100 Subject: [PATCH 18/23] 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 c7117edb14abd38e3488ee010299a36b8c7a6b43 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Wed, 19 Nov 2025 09:27:25 +0100 Subject: [PATCH 19/23] 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 b80dc923b336b1e18c8d0fac1965c6081a87e6b8 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Wed, 19 Nov 2025 11:59:01 +0100 Subject: [PATCH 20/23] NoAsyncRunSynchronouslyInLibrary: updated docs To address feedback. --- docs/content/how-tos/rules/FL0090.md | 38 ++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/content/how-tos/rules/FL0090.md b/docs/content/how-tos/rules/FL0090.md index 372f17028..eb15db0a9 100644 --- a/docs/content/how-tos/rules/FL0090.md +++ b/docs/content/how-tos/rules/FL0090.md @@ -6,7 +6,7 @@ hide_menu: true # NoAsyncRunSynchronouslyInLibrary (FL0090) -*Introduced in `0.26.7`* +*Introduced in `0.26.10`* ## 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. +Your library code might be consumed by certain type of programs which have strict threading requirements (e.g. a long running operation shouldn't be run on the main thread of a desktop app, or it will make the app look like it's hanging for a while), so it's better to expose asynchronous code with `Async<'TResult>` or `Task`/`Task<'TResult>` return types, so that the consumer of your library can decide how/when to start the operation. ## 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.SomeMethodAsync someParam = + self.AsyncSomeMethod someParam + |> Async.StartAsTask +``` + ## Rule Settings { From 1772e71f89a94ead6d38293af8540cdf09f3e720 Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Mon, 22 Dec 2025 10:48:44 +0100 Subject: [PATCH 21/23] NoAsyncRunSynchronouslyInLibrary: refactoring Use new function `howLikelyProjectIsTestOrLibrary` instead of `isInTestProject` and made unit tests for this function. --- src/FSharpLint.Core/FSharpLint.Core.fsproj | 2 +- .../NoAsyncRunSynchronouslyInLibrary.fs | 56 ++++++++++++------- .../NoAsyncRunSynchronouslyInLibrary.fs | 38 +++++++++++++ 3 files changed, 74 insertions(+), 22 deletions(-) diff --git a/src/FSharpLint.Core/FSharpLint.Core.fsproj b/src/FSharpLint.Core/FSharpLint.Core.fsproj index 867a4ce37..8f7ece193 100644 --- a/src/FSharpLint.Core/FSharpLint.Core.fsproj +++ b/src/FSharpLint.Core/FSharpLint.Core.fsproj @@ -70,7 +70,6 @@ - @@ -124,6 +123,7 @@ + diff --git a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs index 7c64fc582..3e42faf7e 100644 --- a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs +++ b/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs @@ -10,7 +10,12 @@ open FSharpLint.Framework.Ast open FSharpLint.Framework.Rules open FSharpLint.Framework.Utilities -let hasEntryPoint (checkFileResults: FSharpCheckFileResults) (maybeProjectCheckResults: FSharpCheckProjectResults option) = +type LibraryHeuristicResultByProjectName = + | Likely + | Unlikely + | Uncertain + +let hasEntryPoint (checkFileResults: FSharpCheckFileResults) (maybeProjectCheckResults: Option) = let hasEntryPointInTheSameFile = match checkFileResults.ImplementationFile with | Some implFile -> implFile.HasExplicitEntryPoint @@ -22,19 +27,21 @@ let hasEntryPoint (checkFileResults: FSharpCheckFileResults) (maybeProjectCheckR | Some projectCheckResults -> projectCheckResults.AssemblyContents.ImplementationFiles |> Seq.exists (fun implFile -> implFile.HasExplicitEntryPoint) - | None -> false + | None -> + false let excludedProjectNames = [ "test"; "console" ] -let isInTestProject (checkFileResults: FSharpCheckFileResults) = - let namespaceIncludesTest = - match checkFileResults.ImplementationFile with - | Some implFile -> - excludedProjectNames |> List.exists (fun name -> implFile.QualifiedName.ToLowerInvariant().Contains name) - | None -> false - let projectFileInfo = System.IO.FileInfo checkFileResults.ProjectContext.ProjectOptions.ProjectFileName - namespaceIncludesTest - || excludedProjectNames |> List.exists (fun name -> projectFileInfo.Name.ToLowerInvariant().Contains name) +let howLikelyProjectIsLibrary (projectFileName: string): LibraryHeuristicResultByProjectName = + let nameSegments = Helper.Naming.QuickFixes.splitByCaseChange projectFileName + if nameSegments |> Seq.contains "Lib" then + Likely + elif excludedProjectNames |> List.exists (fun name -> projectFileName.ToLowerInvariant().Contains name) then + Unlikely + elif projectFileName.ToLowerInvariant().EndsWith "lib" then + Likely + else + Uncertain let extractAttributeNames (attributes: SynAttributes) = seq { @@ -47,7 +54,7 @@ let extractAttributeNames (attributes: SynAttributes) = let testMethodAttributes = [ "Test"; "TestMethod" ] let testClassAttributes = [ "TestFixture"; "TestClass" ] -let isInTheSameModuleAsTest (nodes: array) (maybeProjectCheckResults: FSharpCheckProjectResults option) = +let areThereTestsInSameFileOrProject (nodes: array) (maybeProjectCheckResults: FSharpCheckProjectResults option) = let isTestMethodOrClass node = match node with | AstNode.MemberDefinition(SynMemberDefn.Member(SynBinding(_, _, _, _, attributes, _, _, _, _, _, _, _, _), _)) -> @@ -94,15 +101,22 @@ let isInObsoleteMethodOrFunction parents = 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 -> - isInObsoleteMethodOrFunction (args.GetParents args.NodeIndex) - || isInTheSameModuleAsTest args.SyntaxArray args.ProjectCheckInfo + isInObsoleteMethodOrFunction (args.GetParents args.NodeIndex) + || + match (args.CheckInfo, args.ProjectCheckInfo) with + | Some checkFileResults, Some checkProjectResults -> + let projectFile = System.IO.FileInfo checkProjectResults.ProjectContext.ProjectOptions.ProjectFileName + match howLikelyProjectIsLibrary projectFile.Name with + | Likely -> false + | Unlikely -> true + | Uncertain -> + hasEntryPoint checkFileResults args.ProjectCheckInfo + || areThereTestsInSameFileOrProject args.SyntaxArray args.ProjectCheckInfo + | Some checkFileResults, None -> + hasEntryPoint checkFileResults None + || areThereTestsInSameFileOrProject args.SyntaxArray args.ProjectCheckInfo + | _ -> + areThereTestsInSameFileOrProject 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 03e6f67c2..289f92c3e 100644 --- a/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs +++ b/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs @@ -3,6 +3,7 @@ open NUnit.Framework open FSharpLint.Framework.Rules open FSharpLint.Rules +open FSharpLint.Rules.NoAsyncRunSynchronouslyInLibrary [] type TestNoAsyncRunSynchronouslyInLibrary() = @@ -130,3 +131,40 @@ let Foo() = |> Async.RunSynchronously""") this.AssertNoWarnings() + +[] +type TestNoAsyncRunSynchronouslyInLibraryHeuristic() = + [] + member this.``Unlikely to be library if contains "test" in name``() = + Assert.AreEqual( + howLikelyProjectIsLibrary "TestProject", + LibraryHeuristicResultByProjectName.Unlikely + ) + + [] + member this.``Unlikely to be library if contains "console" in name``() = + Assert.AreEqual( + howLikelyProjectIsLibrary "FooConsole", + LibraryHeuristicResultByProjectName.Unlikely + ) + + [] + member this.``Likely to be library if contains Contains "Lib" as a PascalCase segment``() = + Assert.AreEqual( + howLikelyProjectIsLibrary "LibFoo", + LibraryHeuristicResultByProjectName.Likely + ) + + [] + member this.``Uncertain if contains contains "Lib" but not as a PascalCase segment``() = + Assert.AreEqual( + howLikelyProjectIsLibrary "LibreOfficeProg", + LibraryHeuristicResultByProjectName.Uncertain + ) + + [] + member this.``Likely to be library if contains ends with "lib" (case-insensitive)``() = + Assert.AreEqual( + howLikelyProjectIsLibrary "FooLib", + LibraryHeuristicResultByProjectName.Likely + ) From 2cca9d289076e86448f9c0e22051e16f26bf04bd Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 6 Jan 2026 11:13:04 +0100 Subject: [PATCH 22/23] NoAsyncRunSynchronouslyInLibrary: moved to Smells Category, because its violations are more like a mistake, not a deviation from a certain standard. Also moved Smells folder down in the project, because NoAsyncRunSynchronouslyInLibrary depends on function from NamingHelpers, and that module is in Conventions/Naming. Conventions folder was above Smells, which made NamingHelpers inaccessible to NoAsyncRunSynchronouslyInLibrary. --- src/FSharpLint.Core/FSharpLint.Core.fsproj | 22 +++++++++---------- .../NoAsyncRunSynchronouslyInLibrary.fs | 0 .../FSharpLint.Core.Tests.fsproj | 2 +- .../NoAsyncRunSynchronouslyInLibrary.fs | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) rename src/FSharpLint.Core/Rules/{Conventions => Smells}/NoAsyncRunSynchronouslyInLibrary.fs (100%) rename tests/FSharpLint.Core.Tests/Rules/{Conventions => Smells}/NoAsyncRunSynchronouslyInLibrary.fs (97%) diff --git a/src/FSharpLint.Core/FSharpLint.Core.fsproj b/src/FSharpLint.Core/FSharpLint.Core.fsproj index 8f7ece193..e0810b760 100644 --- a/src/FSharpLint.Core/FSharpLint.Core.fsproj +++ b/src/FSharpLint.Core/FSharpLint.Core.fsproj @@ -31,7 +31,6 @@ - @@ -47,7 +46,6 @@ - @@ -58,16 +56,8 @@ - - - - - - - - @@ -123,9 +113,19 @@ - + + + + + + + + + + + diff --git a/src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/src/FSharpLint.Core/Rules/Smells/NoAsyncRunSynchronouslyInLibrary.fs similarity index 100% rename from src/FSharpLint.Core/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs rename to src/FSharpLint.Core/Rules/Smells/NoAsyncRunSynchronouslyInLibrary.fs diff --git a/tests/FSharpLint.Core.Tests/FSharpLint.Core.Tests.fsproj b/tests/FSharpLint.Core.Tests/FSharpLint.Core.Tests.fsproj index 07b067b11..d57f7fff5 100644 --- a/tests/FSharpLint.Core.Tests/FSharpLint.Core.Tests.fsproj +++ b/tests/FSharpLint.Core.Tests/FSharpLint.Core.Tests.fsproj @@ -14,6 +14,7 @@ + @@ -49,7 +50,6 @@ - diff --git a/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs b/tests/FSharpLint.Core.Tests/Rules/Smells/NoAsyncRunSynchronouslyInLibrary.fs similarity index 97% rename from tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs rename to tests/FSharpLint.Core.Tests/Rules/Smells/NoAsyncRunSynchronouslyInLibrary.fs index 289f92c3e..65e95c40a 100644 --- a/tests/FSharpLint.Core.Tests/Rules/Conventions/NoAsyncRunSynchronouslyInLibrary.fs +++ b/tests/FSharpLint.Core.Tests/Rules/Smells/NoAsyncRunSynchronouslyInLibrary.fs @@ -1,4 +1,4 @@ -module FSharpLint.Core.Tests.Rules.Conventions.NoAsyncRunSynchronouslyInLibrary +module FSharpLint.Core.Tests.Rules.Smells.NoAsyncRunSynchronouslyInLibrary open NUnit.Framework open FSharpLint.Framework.Rules From 0ea5daaed1b55b7cf806c936b9cfa53e93d6ae8d Mon Sep 17 00:00:00 2001 From: webwarrior-ws Date: Tue, 6 Jan 2026 13:00:15 +0100 Subject: [PATCH 23/23] NoAsyncRunSynchronouslyInLibrary: look at method attrs In addition to class attribute when checking file declarations to decide if the class is test class because in later NUnit versions it is optional to mark the class, only methods must be marked with test attribute. --- .../Smells/NoAsyncRunSynchronouslyInLibrary.fs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/FSharpLint.Core/Rules/Smells/NoAsyncRunSynchronouslyInLibrary.fs b/src/FSharpLint.Core/Rules/Smells/NoAsyncRunSynchronouslyInLibrary.fs index 3e42faf7e..601d476de 100644 --- a/src/FSharpLint.Core/Rules/Smells/NoAsyncRunSynchronouslyInLibrary.fs +++ b/src/FSharpLint.Core/Rules/Smells/NoAsyncRunSynchronouslyInLibrary.fs @@ -67,11 +67,20 @@ let areThereTestsInSameFileOrProject (nodes: array) (m |> Seq.exists (fun name -> testClassAttributes |> List.contains name) | _ -> false - let isDeclarationOfTestClass declaration = + let containsTests declaration = match declaration with - | FSharpImplementationFileDeclaration.Entity(entity, _) -> + | FSharpImplementationFileDeclaration.Entity(entity, declarations) when entity.IsClass -> entity.Attributes |> Seq.exists (fun attr -> testClassAttributes |> List.contains attr.AttributeType.DisplayName) + || + declarations + |> Seq.exists + (fun memberDecl -> + match memberDecl with + | FSharpImplementationFileDeclaration.MemberOrFunctionOrValue(method, _, _) when method.IsMethod -> + method.Attributes + |> Seq.exists (fun attr -> testMethodAttributes |> List.contains attr.AttributeType.DisplayName) + | _ -> false) | _ -> false match maybeProjectCheckResults with @@ -79,7 +88,7 @@ let areThereTestsInSameFileOrProject (nodes: array) (m projectCheckResults.AssemblyContents.ImplementationFiles |> Seq.exists (fun implFile -> implFile.Declarations - |> Seq.exists isDeclarationOfTestClass + |> Seq.exists containsTests ) | None -> nodes |> Array.exists (fun node -> isTestMethodOrClass node.Actual)