From 744b8bed5564af97d37610cc135a8db3910379a9 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 10 Oct 2026 23:21:04 +0200 Subject: [PATCH 01/19] chore: migrate to .NET SDK `10.0.401` Update `Microsoft.SourceLink.GitHub` to `10.0.401` together with the SDK it ships with. Co-Authored-By: Claude Opus 5.5 --- Directory.Packages.props | 2 +- global.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5639be71..02ec46b3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,7 +25,7 @@ - + diff --git a/global.json b/global.json index 70c8f035..d3238643 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.300", + "version": "10.0.401", "rollForward": "latestMinor" }, "test": { From d0e89dc8ab84020da8787e7e93d47bdc581a160f Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 10 Oct 2026 23:21:29 +0200 Subject: [PATCH 02/19] chore: align script line endings between .gitattributes and .editorconfig Batch files are checked out with CRLF and PowerShell scripts with LF on every platform, and `.editorconfig` states the same instead of asking for CRLF in PowerShell scripts. The inline comments of `.editorconfig` move onto their own lines: the specification does not support inline comments, so they were read as part of the value. Co-Authored-By: Claude Opus 5.5 --- .editorconfig | 22 ++++++++++++++-------- .gitattributes | 6 ++++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.editorconfig b/.editorconfig index 869005af..656982fe 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,5 +1,5 @@ -# EditorConfig is awesome: -http://EditorConfig.org +# EditorConfig is awesome: https://EditorConfig.org +# Comments must be on their own lines: the specification does not support inline comments # top-most EditorConfig file root = true @@ -8,7 +8,8 @@ root = true # Core EditorConfig Options # ############################### # All files -[*] # Do not apply to all files not to break something +# Do not apply to all files not to break something +[*] guidelines = 120 dashed, 130 # Either crlf | lf, default is system-dependent (when not specified at all) # end_of_line=crlf @@ -22,8 +23,10 @@ indent_style = tab [*.{cs,csx,fs,fsi,fsx}] trim_trailing_whitespace = true insert_final_newline = true -indent_style = space # default=space -indent_size = 4 # default=4 +# default=space +indent_style = space +# default=4 +indent_size = 4 charset = utf-8 # Project files and app specific XML files @@ -42,7 +45,8 @@ indent_size = 2 # XML files [*.xml] -trim_trailing_whitespace = false # do not trim as it affects CData +# do not trim as it affects CData +trim_trailing_whitespace = false insert_final_newline = true indent_style = space indent_size = 2 @@ -70,12 +74,14 @@ trim_trailing_whitespace = false end_of_line = lf # Batch Files +# keep in sync with .gitattributes [*.{cmd,bat}] end_of_line = crlf # Powershell Files -[*.{ps1, psm1}] -end_of_line = crlf +# keep in sync with .gitattributes +[*.{ps1,psm1}] +end_of_line = lf # Paket files [paket.*] diff --git a/.gitattributes b/.gitattributes index cb6f883d..df5e1a85 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,6 +13,12 @@ *.fsproj merge=union *.dbproj merge=union *.sh text eol=lf +# cmd.exe misreads labels and goto in LF batch files +*.cmd text eol=crlf +*.bat text eol=crlf +# LF keeps the `#!/usr/bin/env pwsh` shebang working on Linux; PowerShell on Windows reads LF too +*.ps1 text eol=lf +*.psm1 text eol=lf # Standard to msysgit *.doc diff=astextplain From b6a4273e26dd6b6451ae8cb824c6e936f0b304d7 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 10 Oct 2026 23:21:38 +0200 Subject: [PATCH 03/19] chore(deps): update Fantomas to `8.0.5` `.editorconfig` follows the settings of Fantomas 8: - `fsharp_keep_indent_in_branch` became `fsharp_experimental_keep_indent_in_branch`; under its old name the setting was ignored. - `fsharp_semicolon_at_end_of_line` and `fsharp_max_dot_get_expression_width` no longer exist. - `end_of_line=lf` matches the line endings `.gitattributes` stores F# files with, so formatting on Windows no longer rewrites them as CRLF. The comments state the current defaults and link to the current documentation. Co-Authored-By: Claude Opus 5.5 --- .config/dotnet-tools.json | 2 +- .editorconfig | 43 +++++++++++++++++---------------------- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 3c0225ff..9d1a9673 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "fantomas": { - "version": "7.0.2", + "version": "8.0.5", "commands": [ "fantomas" ], diff --git a/.editorconfig b/.editorconfig index 656982fe..c4537e1f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -111,16 +111,12 @@ indent_size=4 max_line_length=130 # Either crlf | lf, default is system-dependent (when not specified at all) -# end_of_line=crlf +# lf matches the eol .gitattributes stores F# files with, so formatting does not touch line endings +end_of_line=lf # Whether end-of-file has a newline, default=true insert_final_newline=true -# false: someLineOfCode -# true: someLineOfCode; -# default false -fsharp_semicolon_at_end_of_line=false - # false: f(1,2) # true: f(1, 2) # default true @@ -141,8 +137,8 @@ fsharp_space_before_uppercase_invocation=true # default false fsharp_space_before_class_constructor=true -# false: __.member Foo(x) = x -# true: __.member Foo (x) = x +# false: member this.Foo(x) = x +# true: member this.Foo (x) = x # default false fsharp_space_before_member=true @@ -166,18 +162,18 @@ fsharp_space_before_semicolon=false # default true fsharp_space_after_semicolon=true -# false: let a = [1;2;3] -# true: let a = [ 1;2;3 ] +# false: let a = [1; 2; 3] +# true: let a = [ 1; 2; 3 ] # default true fsharp_space_around_delimiter=true # breaks an if-then-else in smaller parts if it is on one line -# default 40 +# default 60 fsharp_max_if_then_else_short_width=60 # breaks an infix operator expression if it is on one line # infix: a + b + c -# default 50 +# default 80 fsharp_max_infix_operator_expression=60 # breaks a single-line record declaration @@ -198,7 +194,7 @@ fsharp_max_record_number_of_items=1 fsharp_record_multiline_formatter=character_width # breaks a single line array or list if it exceeds this size -# default 40 +# default 80 fsharp_max_array_or_list_width=100 # breaks an array or list into one item per line if items exceeds this number @@ -213,7 +209,7 @@ fsharp_max_array_or_list_number_of_items=1 # default character_width fsharp_array_or_list_multiline_formatter=character_width -# maximum with of a value binding, does not include keyword "let" +# maximum width of a value binding, does not include keyword "let" # default 80 fsharp_max_value_binding_width=100 @@ -221,10 +217,6 @@ fsharp_max_value_binding_width=100 # default 40 fsharp_max_function_binding_width=80 -# maximum width for expressions like X.DoY().GetZ(10).Help() -# default 50 -fsharp_max_dot_get_expression_width=80 - # whether open/close brackets go on the same column # cramped: type Range = # { From: float @@ -238,10 +230,10 @@ fsharp_max_dot_get_expression_width=80 # From: float # To: float # } -# default cramped +# default aligned fsharp_multiline_bracket_style=stroustrup -# whether to move the beginning of compuitation expression to the new line +# whether to move the beginning of computation expression to the new line # true: let x = # computation { # ... @@ -249,6 +241,7 @@ fsharp_multiline_bracket_style=stroustrup # false: let x = computation { # .. # } +# default true fsharp_newline_before_multiline_computation_expression=false # whether a newline should be placed before members @@ -259,7 +252,7 @@ fsharp_newline_before_multiline_computation_expression=false # { From: float } # # member this.Length = this.To - this.From -# default false +# default true fsharp_newline_between_type_definition_and_members=true # if a function sign exceeds max_line_length, then: @@ -268,7 +261,7 @@ fsharp_newline_between_type_definition_and_members=true # default false fsharp_align_function_signature_to_indentation=false -# see docs: https://github.com/fsprojects/fantomas/blob/master/docs/Documentation.md#fsharp_alternative_long_member_definitions +# see docs: https://fsprojects.github.io/fantomas/docs/end-users/Configuration.html#fsharp_alternative_long_member_definitions # default false fsharp_alternative_long_member_definitions=true @@ -276,7 +269,8 @@ fsharp_alternative_long_member_definitions=true # default false fsharp_multi_line_lambda_closing_newline=true -# allows the 'else'-branch to be aligned at same level as 'else' if the ret type allows it +# keeps the last branch of if/then/else or match at the same indentation as the expression (early-return style) +# see docs: https://fsprojects.github.io/fantomas/docs/end-users/Configuration.html#fsharp_experimental_keep_indent_in_branch # false: match x with # | null -> () # | _ -> () @@ -285,7 +279,7 @@ fsharp_multi_line_lambda_closing_newline=true # | _ -> # () # default false -fsharp_keep_indent_in_branch=true +fsharp_experimental_keep_indent_in_branch=true # whether a bar is placed before DU # false: type MyDU = Short of int @@ -299,4 +293,5 @@ fsharp_blank_lines_around_nested_multiline_expressions=false # set maximal number of consecutive blank lines to keep from original source # it doesn't change number of new blank lines generated by Fantomas +# default 100 fsharp_keep_max_number_of_blank_lines=2 From 0f8c45b005dc4b65a676ce59678474e6b951bc5d Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 10 Oct 2026 23:22:04 +0200 Subject: [PATCH 04/19] style: reformat with Fantomas `8.0.5` Fantomas 8 no longer puts a space between a type application and its argument list: `Create<'T> ()` becomes `Create<'T>()`. Whitespace only. Co-Authored-By: Claude Opus 5.5 --- build/Changelog.fs | 2 +- build/build.fs | 2 +- src/FSharp.Collections.Immutable/FlatList.fs | 24 +++++++++---------- .../ImmutableList.fs | 4 ++-- src/FSharp.Collections.Immutable/Maps.fs | 12 +++++----- src/FSharp.Collections.Immutable/Queue.fs | 4 ++-- src/FSharp.Collections.Immutable/Sets.fs | 12 +++++----- src/FSharp.Collections.Immutable/Stack.fs | 2 +- 8 files changed, 31 insertions(+), 31 deletions(-) diff --git a/build/Changelog.fs b/build/Changelog.fs index 308013c5..7026b3de 100644 --- a/build/Changelog.fs +++ b/build/Changelog.fs @@ -64,7 +64,7 @@ let mkReleaseNotes changelog (latestEntry : Changelog.ChangelogEntry) gitHubRepo | Some desc when desc.Contains (linkReference) -> desc | Some desc -> sprintf "%s\n\n%s" (desc.Trim ()) linkReference - { latestEntry with Description = Some description }.ToString () + { latestEntry with Description = Some description }.ToString() let getVersionNumber envVarName ctx = let args = ctx.Context.Arguments diff --git a/build/build.fs b/build/build.fs index 441de3e0..bad1b6f1 100644 --- a/build/build.fs +++ b/build/build.fs @@ -338,7 +338,7 @@ let dotnetBuild ctx = sln let fsharpAnalyzers _ = - let argParser = ArgumentParser.Create (programName = "fsharp-analyzers") + let argParser = ArgumentParser.Create(programName = "fsharp-analyzers") !!srcGlob |> Seq.iter (fun proj -> diff --git a/src/FSharp.Collections.Immutable/FlatList.fs b/src/FSharp.Collections.Immutable/FlatList.fs index 82bcce74..b6153265 100644 --- a/src/FSharp.Collections.Immutable/FlatList.fs +++ b/src/FSharp.Collections.Immutable/FlatList.fs @@ -23,8 +23,8 @@ module FlatList = ////////// Creating ////////// - let inline empty<'T> : FlatList<_> = FlatListFactory.Create<'T> () - let inline singleton<'T> (item : 'T) : FlatList<'T> = FlatListFactory.Create<'T> (item) + let inline empty<'T> : FlatList<_> = FlatListFactory.Create<'T>() + let inline singleton<'T> (item : 'T) : FlatList<'T> = FlatListFactory.Create<'T>(item) let inline ofSeq source = FlatListFactory.CreateRange source let inline ofArray (source : _ array) = FlatListFactory.CreateRange source @@ -234,7 +234,7 @@ module FlatList = let iter2 action list1 list2 = checkNotDefault (nameof list1) list1 checkNotDefault (nameof list2) list2 - let f = OptimizedClosures.FSharpFunc<'T, 'U, unit>.Adapt (action) + let f = OptimizedClosures.FSharpFunc<'T, 'U, unit>.Adapt(action) let len = length list1 if len <> length list2 then @@ -245,7 +245,7 @@ module FlatList = let distinctBy projection (list : FlatList<'T>) = let builder : FlatList<'T>.Builder = builderWith <| length list - let set = System.Collections.Generic.HashSet<'Key> (HashIdentity.Structural) + let set = System.Collections.Generic.HashSet<'Key>(HashIdentity.Structural) let mutable outputIndex = 0 for i = 0 to length list - 1 do @@ -260,7 +260,7 @@ module FlatList = let map2 mapping list1 list2 = checkNotDefault (nameof list1) list1 checkNotDefault (nameof list2) list2 - let f = OptimizedClosures.FSharpFunc<_, _, _>.Adapt (mapping) + let f = OptimizedClosures.FSharpFunc<_, _, _>.Adapt(mapping) let len1 = list1.Length if len1 <> list2.Length then @@ -277,7 +277,7 @@ module FlatList = checkNotDefault (nameof list1) list1 checkNotDefault (nameof list2) list2 checkNotDefault (nameof list3) list3 - let f = OptimizedClosures.FSharpFunc<_, _, _, _>.Adapt (mapping) + let f = OptimizedClosures.FSharpFunc<_, _, _, _>.Adapt(mapping) let len1 = list1.Length if not (len1 = list2.Length) then @@ -296,7 +296,7 @@ module FlatList = let mapi2 mapping list1 list2 = checkNotDefault (nameof list1) list1 checkNotDefault (nameof list2) list2 - let f = OptimizedClosures.FSharpFunc<_, _, _, _>.Adapt (mapping) + let f = OptimizedClosures.FSharpFunc<_, _, _, _>.Adapt(mapping) let len1 = list1.Length if len1 <> list2.Length then @@ -311,7 +311,7 @@ module FlatList = let iteri action list = check list - let f = OptimizedClosures.FSharpFunc<_, _, _>.Adapt (action) + let f = OptimizedClosures.FSharpFunc<_, _, _>.Adapt(action) let len = list.Length for i = 0 to len - 1 do @@ -320,7 +320,7 @@ module FlatList = let iteri2 action list1 list2 = checkNotDefault (nameof list1) list1 checkNotDefault (nameof list2) list2 - let f = OptimizedClosures.FSharpFunc<_, _, _, _>.Adapt (action) + let f = OptimizedClosures.FSharpFunc<_, _, _, _>.Adapt(action) let len1 = list1.Length if len1 <> list2.Length then @@ -331,7 +331,7 @@ module FlatList = let mapi mapping list = check list - let f = OptimizedClosures.FSharpFunc<_, _, _>.Adapt (mapping) + let f = OptimizedClosures.FSharpFunc<_, _, _>.Adapt(mapping) let len = list.Length let res = builderWithLengthOf list @@ -360,7 +360,7 @@ module FlatList = let exists2 predicate list1 list2 = checkNotDefault (nameof list1) list1 checkNotDefault (nameof list2) list2 - let f = OptimizedClosures.FSharpFunc<_, _, _>.Adapt (predicate) + let f = OptimizedClosures.FSharpFunc<_, _, _>.Adapt(predicate) let len1 = list1.Length if len1 <> list2.Length then @@ -381,7 +381,7 @@ module FlatList = let forall2 predicate list1 list2 = checkNotDefault (nameof list1) list1 checkNotDefault (nameof list2) list2 - let f = OptimizedClosures.FSharpFunc<_, _, _>.Adapt (predicate) + let f = OptimizedClosures.FSharpFunc<_, _, _>.Adapt(predicate) let len1 = list1.Length if len1 <> list2.Length then diff --git a/src/FSharp.Collections.Immutable/ImmutableList.fs b/src/FSharp.Collections.Immutable/ImmutableList.fs index 48ced7c7..6683f84a 100644 --- a/src/FSharp.Collections.Immutable/ImmutableList.fs +++ b/src/FSharp.Collections.Immutable/ImmutableList.fs @@ -14,8 +14,8 @@ module ImmutableList = let inline internal check (list : IImmutableList<_>) = checkNotNull (nameof list) list - let inline empty<'T> = ImmutableList.Create<'T> () - let inline singleton<'T> (item : 'T) : ImmutableList<'T> = ImmutableList.Create<'T> (item) + let inline empty<'T> = ImmutableList.Create<'T>() + let inline singleton<'T> (item : 'T) : ImmutableList<'T> = ImmutableList.Create<'T>(item) let inline ofSeq source = checkNotNull (nameof source) source diff --git a/src/FSharp.Collections.Immutable/Maps.fs b/src/FSharp.Collections.Immutable/Maps.fs index 12f5d638..7a73565a 100644 --- a/src/FSharp.Collections.Immutable/Maps.fs +++ b/src/FSharp.Collections.Immutable/Maps.fs @@ -19,7 +19,7 @@ module HashMap = ////////// Creating ////////// - let inline empty<'Key, 'Value when 'Key : not null> = HashMapFactory.Create<'Key, 'Value> () + let inline empty<'Key, 'Value when 'Key : not null> = HashMapFactory.Create<'Key, 'Value>() let inline singleton item = empty.Add (item) let inline ofSeq source = HashMapFactory.CreateRange (source) @@ -57,9 +57,9 @@ module HashMap = check map map.ToBuilder () - let inline ofKeyComparer<'Key, 'Value when 'Key : not null> comparer = HashMapFactory.Create<'Key, 'Value> (comparer) + let inline ofKeyComparer<'Key, 'Value when 'Key : not null> comparer = HashMapFactory.Create<'Key, 'Value>(comparer) let inline ofComparers<'Key, 'Value when 'Key : not null> keyComparer valueComparer = - HashMapFactory.Create<'Key, 'Value> (keyComparer, valueComparer) + HashMapFactory.Create<'Key, 'Value>(keyComparer, valueComparer) let inline isEmpty map = @@ -178,7 +178,7 @@ module SortedMap = ////////// Creating ////////// - let inline empty<'Key, 'Value when 'Key : not null> = SortedMapFactory.Create<'Key, 'Value> () + let inline empty<'Key, 'Value when 'Key : not null> = SortedMapFactory.Create<'Key, 'Value>() let inline singleton item = SortedMapFactory.Create (item) let inline ofSeq source = SortedMapFactory.CreateRange (source) @@ -215,9 +215,9 @@ module SortedMap = check map map.ToBuilder () - let inline ofKeyComparer<'Key, 'Value when 'Key : not null> comparer = SortedMapFactory.Create<'Key, 'Value> (comparer) + let inline ofKeyComparer<'Key, 'Value when 'Key : not null> comparer = SortedMapFactory.Create<'Key, 'Value>(comparer) let inline ofComparers<'Key, 'Value when 'Key : not null> keyComparer valueComparer = - SortedMapFactory.Create<'Key, 'Value> (keyComparer, valueComparer) + SortedMapFactory.Create<'Key, 'Value>(keyComparer, valueComparer) let inline isEmpty map = diff --git a/src/FSharp.Collections.Immutable/Queue.fs b/src/FSharp.Collections.Immutable/Queue.fs index dd8d64fe..ba9d1f39 100644 --- a/src/FSharp.Collections.Immutable/Queue.fs +++ b/src/FSharp.Collections.Immutable/Queue.fs @@ -13,9 +13,9 @@ module Queue = let inline private check (queue : IQueue<_>) = checkNotNull (nameof queue) queue - let inline empty<'T> : Queue<'T> = QueueFactory.Create<'T> () + let inline empty<'T> : Queue<'T> = QueueFactory.Create<'T>() - let inline singleton<'T> (item : 'T) : Queue<'T> = QueueFactory.Create<'T> (item) + let inline singleton<'T> (item : 'T) : Queue<'T> = QueueFactory.Create<'T>(item) let inline ofSeq (source : 'T seq) : Queue<'T> = QueueFactory.CreateRange source diff --git a/src/FSharp.Collections.Immutable/Sets.fs b/src/FSharp.Collections.Immutable/Sets.fs index cbdba229..8d94e56e 100644 --- a/src/FSharp.Collections.Immutable/Sets.fs +++ b/src/FSharp.Collections.Immutable/Sets.fs @@ -16,8 +16,8 @@ module HashSet = ////////// Creating ////////// - let inline empty<'T> = HashSetFactory.Create<'T> () - let inline singleton<'T> (item : 'T) = HashSetFactory.Create<'T> (item) + let inline empty<'T> = HashSetFactory.Create<'T>() + let inline singleton<'T> (item : 'T) = HashSetFactory.Create<'T>(item) let inline ofSeq source = HashSetFactory.CreateRange (source) let inline ofSeqWithComparer comparer source = HashSetFactory.Create (comparer, items = (source |> Array.ofSeq)) let inline ofArray (source : _ array) = HashSetFactory.CreateRange (source) @@ -26,7 +26,7 @@ module HashSet = checkNotNull (nameof hashSetBuilder) hashSetBuilder hashSetBuilder.ToImmutable () - let inline ofComparer<'T> comparer = HashSetFactory.Create<'T> (equalityComparer = comparer) + let inline ofComparer<'T> comparer = HashSetFactory.Create<'T>(equalityComparer = comparer) let inline toSeq (set : HashSet<_>) = set :> seq<_> @@ -149,8 +149,8 @@ module SortedSet = ////////// Creating ////////// - let inline empty<'T> = SortedSetFactory.Create<'T> () - let inline singleton<'T> (item : 'T) = SortedSetFactory.Create<'T> (item) + let inline empty<'T> = SortedSetFactory.Create<'T>() + let inline singleton<'T> (item : 'T) = SortedSetFactory.Create<'T>(item) let inline ofSeq source = SortedSetFactory.CreateRange (source) let inline ofSeqWithComparer comparer source = SortedSetFactory.Create (comparer, items = (source |> Array.ofSeq)) let inline ofArray (source : _ array) = SortedSetFactory.CreateRange (source) @@ -159,7 +159,7 @@ module SortedSet = checkNotNull (nameof sortedSetBuilder) sortedSetBuilder sortedSetBuilder.ToImmutable () - let inline ofComparer<'T> comparer = SortedSetFactory.Create<'T> (comparer = comparer) + let inline ofComparer<'T> comparer = SortedSetFactory.Create<'T>(comparer = comparer) let inline toSeq (set : SortedSet<_>) = set :> seq<_> diff --git a/src/FSharp.Collections.Immutable/Stack.fs b/src/FSharp.Collections.Immutable/Stack.fs index a9502e53..774ad6b5 100644 --- a/src/FSharp.Collections.Immutable/Stack.fs +++ b/src/FSharp.Collections.Immutable/Stack.fs @@ -12,7 +12,7 @@ module Stack = let inline internal check (stack : IStack<_>) = checkNotNull (nameof stack) stack - let inline empty<'T> = StackFactory.Create<'T> () + let inline empty<'T> = StackFactory.Create<'T>() let inline ofSeq source = StackFactory.CreateRange source From 0e1b6c07387d75cfeddaf62a3eea860e866509f3 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 10 Oct 2026 23:23:34 +0200 Subject: [PATCH 05/19] fix(build): restore tools before restore and format each project once `ToolRestore` ran only in design-time builds, so a command-line build relied on the FAKE `DotnetToolRestore` target, and that target was a dependency of `BuildDocs` alone: on CI `CheckFormatCode` found no Fantomas and passed without checking anything. `ToolRestore` now runs before every restore, and the FAKE target is gone. `ToolUpdate` is removed: its `Outputs` used an undefined property, so MSBuild always skipped it. Formatting is delegated to one instance of a project without a target framework, so a project is formatted once per build however many target frameworks it has, and Fantomas runs twice because one pass is not always idempotent. Co-Authored-By: Claude Opus 5.5 --- Directory.Build.targets | 40 ++++++++++++++++------------------------ build/build.fs | 15 --------------- 2 files changed, 16 insertions(+), 39 deletions(-) diff --git a/Directory.Build.targets b/Directory.Build.targets index 484ac5a1..cdec8ba6 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -11,36 +11,28 @@ See https://learn.microsoft.com/en-us/visualstudio/msbuild/customize-by-director <_DotnetFantomasOutputFile>$(BaseIntermediateOutputPath)dotnet-fantomas-msbuild-$(NETCoreSdkVersion)-$(OS) - - - - - - - - - - - + - - - - + + + + + + + + diff --git a/build/build.fs b/build/build.fs index bad1b6f1..fa184286 100644 --- a/build/build.fs +++ b/build/build.fs @@ -269,15 +269,6 @@ let dotnetRestore _ = ) |> Seq.iter (retryIfInCI 10) -let dotnetToolRestore _ = - let result = - fun () -> DotNet.exec id "tool" "restore" - |> (retryIfInCI 10) - - - if not result.OK then - failwithf "Failed to restore .NET tools: %A" result.Errors - let updateChangelog ctx = latestEntry <- if not <| isPublishToGitHub ctx then @@ -631,7 +622,6 @@ let initTargets (ctx : Context.FakeExecutionContext) = Target.create "Clean" clean Target.create "DotnetRestore" dotnetRestore - Target.create "DotnetToolRestore" dotnetToolRestore Target.create "UpdateChangelog" updateChangelog Target.createBuildFailure "RevertChangelog" revertChangelog // Do NOT put this in the dependency chain Target.createFinal "DeleteChangelogBackupFile" deleteChangelogBackupFile // Do NOT put this in the dependency chain @@ -714,11 +704,6 @@ let initTargets (ctx : Context.FakeExecutionContext) = "DotnetRestore" ==>! "WatchTests" - //"DotnetToolRestore" ?=>! "DotnetRestore" - "DotnetToolRestore" ==>! "BuildDocs" - "DotnetToolRestore" ?=>! "CheckFormatCode" - "DotnetToolRestore" ?=>! "FormatCode" - //----------------------------------------------------------------------------- // Target Start //----------------------------------------------------------------------------- From e719a9bdc6671603f779189e614c9641e108c9b9 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 10 Oct 2026 23:24:48 +0200 Subject: [PATCH 06/19] fix(build): collect coverage with the MSTest runner instead of the AltCover leftovers `DotnetTest` passed `/p:AltCover*` properties although no project references AltCover, and `GenerateCoverageReport` looked for `tests/**/coverage*.xml`, which nothing produced, so `ENABLE_COVERAGE` collected no coverage. `DotnetTest` now runs `dotnet test --project` for each test project and lets the MSTest runner collect coverage with `--coverage --coverage-output-format cobertura` into `TestResults`, where `GenerateCoverageReport` reads it and writes HTML and Cobertura reports. Coverage is collected when `ENABLE_COVERAGE` is set or `GenerateCoverageReport` is among the targets. Also: - the release commit is named `release: bump version to ...`; - `quoted` is shared by the test and the documentation targets; - the workflows no longer explain `ENABLE_COVERAGE: false` with AltCover. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish_ci.yml | 2 +- .github/workflows/publish_release.yml | 2 +- build/build.fs | 86 ++++++++++++++------------- 3 files changed, 48 insertions(+), 42 deletions(-) diff --git a/.github/workflows/publish_ci.yml b/.github/workflows/publish_ci.yml index 34fd6ed3..e464e278 100644 --- a/.github/workflows/publish_ci.yml +++ b/.github/workflows/publish_ci.yml @@ -59,7 +59,7 @@ jobs: NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} FAKE_DETAILED_ERRORS: true - ENABLE_COVERAGE: false # AltCover doesn't work with Release builds, reports lower coverage than actual + ENABLE_COVERAGE: false run: | chmod +x ./build.sh ./build.sh "PublishToGitHub" diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml index 5598491d..6c399004 100644 --- a/.github/workflows/publish_release.yml +++ b/.github/workflows/publish_release.yml @@ -30,7 +30,7 @@ jobs: NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} FAKE_DETAILED_ERRORS: true - ENABLE_COVERAGE: false # AltCover doesn't work with Release builds, reports lower coverage than actual + ENABLE_COVERAGE: false run: | chmod +x ./build.sh ./build.sh Publish diff --git a/build/build.fs b/build/build.fs index fa184286..50879292 100644 --- a/build/build.fs +++ b/build/build.fs @@ -145,12 +145,12 @@ let failOnWrongBranch () = module dotnet = - let watch cmdParam program args = DotNet.exec cmdParam (sprintf "watch %s" program) args + let watch cmdParam program args = DotNet.exec cmdParam $"watch %s{program}" args let run cmdParam args = DotNet.exec cmdParam "run" args - let tool optionConfig command args = - DotNet.exec optionConfig (sprintf "%s" command) args + let tool optionConfig (command : string) args = + DotNet.exec optionConfig command args |> failOnBadExitAndPrint let reportgenerator optionConfig args = tool optionConfig "reportgenerator" args @@ -175,9 +175,9 @@ module FSharpAnalyzers = member s.Usage = "" -module DocsTool = - let quoted s = $"\"%s{s}\"" +let quoted s = $"\"%s{s}\"" +module DocsTool = let fsDocsDotnetOptions (o : DotNet.Options) = { o with WorkingDirectory = rootDirectory } let fsDocsBuildParams configuration (p : Fsdocs.BuildCommandParams) = { @@ -347,37 +347,37 @@ let fsharpAnalyzers _ = ) let dotnetTest ctx = - let excludeCoverage = - !!testsGlob - |> Seq.map (IO.Path.GetFileNameWithoutExtension >> Unchecked.nonNull) - |> String.concat "|" - - let isGenerateCoverageReportTarget = - String.Equals (ctx.Context.FinalTarget, "GenerateCoverageReport", StringComparison.OrdinalIgnoreCase) - || String.Equals (ctx.Context.FinalTarget, "ShowCoverageReport", StringComparison.OrdinalIgnoreCase) - - let shouldCollectCoverage = enableCodeCoverage || isGenerateCoverageReportTarget - - if shouldCollectCoverage then - Directory.create testResultsDir - - let args = - [ - "--no-build" - $"--configuration {configuration (ctx.Context.AllExecutingTargets)}" - if shouldCollectCoverage then - "/p:AltCover=true" - $"/p:AltCoverAssemblyExcludeFilter={excludeCoverage}" - "/p:AltCoverLocalSource=true" - $"--solution \"{sln}\"" - ] - |> String.concat " " + // Create test results directory if it doesn't exist + Directory.create testResultsDir + + let dotnetConfiguration = configuration (ctx.Context.AllExecutingTargets) |> string + + let isGenerateCoverageReport = ctx.Context.TryFindTarget("GenerateCoverageReport").IsSome + + let args = [ + "--no-build" + "--configuration" + dotnetConfiguration + if enableCodeCoverage || isGenerateCoverageReport then + "--coverage" + "--coverage-output-format" + "cobertura" + "--results-directory" + quoted testResultsDir + ] - DotNet.exec (DotNet.Options.withWorkingDirectory rootDirectory) "test" args - |> failOnBadExitAndPrint + !!testsGlob + |> Seq.iter (fun testProject -> + [ "--project"; quoted testProject; yield! args ] + |> String.concat " " + |> DotNet.exec id "test" + |> failOnBadExitAndPrint + ) let generateCoverageReport _ = - let coverageFiles = !!(rootDirectory "tests/**/coverage*.xml") + let coverageFiles = + !!(testResultsDir "*.cobertura.xml") + ++ (testResultsDir "*/coverage.cobertura.xml") let sourceDirs = !!srcGlob |> Seq.map Path.getDirectory |> String.concat ";" @@ -386,9 +386,10 @@ let generateCoverageReport _ = sprintf "-targetdir:\"%s\"" coverageReportDir // Add source dir sprintf "-sourcedirs:\"%s\"" sourceDirs - // Ignore test assemblies and AltCover recorder helpers - sprintf "-assemblyfilters:\"%s\"" "-*.Tests;-AltCover.Recorder.g" - sprintf "-reporttypes:%s" "Html" + // Ignore test assemblies + sprintf "-assemblyfilters:\"%s\"" "-*.Tests" + // Generate HTML and Cobertura reports + sprintf "-reporttypes:%s" "Html;Cobertura" ] let args = independentArgs |> String.concat " " @@ -436,7 +437,7 @@ let generateAssemblyInfo _ = | f when f.EndsWith ("fsproj") -> Fsproj | f when f.EndsWith ("csproj") -> Csproj | f when f.EndsWith ("vbproj") -> Vbproj - | _ -> failwith (sprintf "Project file %s not supported. Unknown project type." projFileName) + | _ -> failwithf "Project file %s not supported. Unknown project type." projFileName let releaseChannel = match latestEntry.SemVer.PreRelease with @@ -530,7 +531,7 @@ let gitRelease _ = ++ (rootDirectory "tests/**/AssemblyInfo.fs") |> Seq.iter (Git.Staging.stageFile "" >> ignore) - let msg = $"Bump version to `%s{latestEntry.NuGetVersion}`\n\n%s{releaseNotesGitCommitFormat}" + let msg = $"release: bump version to `%s{latestEntry.NuGetVersion}`\n\n%s{releaseNotesGitCommitFormat}" Git.Commit.exec "" msg @@ -606,10 +607,15 @@ let initTargets (ctx : Context.FakeExecutionContext) = && String.Equals (value, "PublishToGitHub", StringComparison.OrdinalIgnoreCase) ) - /// Defines a dependency - y is dependent on x. Finishes the chain. + /// + /// Defines a dependency - is dependent on . Finishes the chain. + /// let (==>!) x y = x ==> y |> ignore - /// Defines a soft dependency. x must run before y, if it is present, but y does not require x to be run. Finishes the chain. + /// + /// Defines a soft dependency. must run before , if it is present, but + /// does not require to be run. Finishes the chain. + /// let (?=>!) x y = x ?=> y |> ignore //----------------------------------------------------------------------------- // Hide Secrets in Logger From 68ef0c6f4dd8bba13482d36e9d5d20c271c5f294 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 10 Oct 2026 23:25:13 +0200 Subject: [PATCH 07/19] chore: add src/Directory.Build.props and group the solution into folders `src/Directory.Build.props` holds what every library project shares, next to the `tests/Directory.Build.props` the test projects already have: it marks the project as packable, generates the documentation file, publishes the repository URL, embeds untracked sources and puts the PDB into the package next to the assembly. The solution groups the projects into `src` and `tests` folders. Co-Authored-By: Claude Opus 5.5 --- FSharp.Collections.Immutable.slnx | 10 +++++++--- src/Directory.Build.props | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 src/Directory.Build.props diff --git a/FSharp.Collections.Immutable.slnx b/FSharp.Collections.Immutable.slnx index 4e8a5d03..b2ce8282 100644 --- a/FSharp.Collections.Immutable.slnx +++ b/FSharp.Collections.Immutable.slnx @@ -9,7 +9,11 @@ - - - + + + + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 00000000..5ea09a4f --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,14 @@ + + + + true + true + false + + + true + true + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + + + From 070238e8d6429fc8bfa28751e7d32b04793c5518 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 10 Oct 2026 23:32:06 +0200 Subject: [PATCH 08/19] fix(build): generate the assembly info when the latest changelog entry has no date `GenerateAssemblyInfo` read `latestEntry.Date.Value` and failed with a `NullReferenceException` for an entry without a date. The `ReleaseDate` metadata is now written only when the entry has one. This is the case in this repository today: FAKE `6.1.4` moves the link references out of the last entry of the changelog and rebuilds that entry from its versions and changes alone, dropping its date and description (fixed in FAKE after `6.1.4`, not released yet). `1.0.0` is the only entry, so it is both the latest and the last one, and `PublishToGitHub`, which generates the assembly info, could not get past this target. Co-Authored-By: Claude Opus 5.5 --- build/build.fs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/build/build.fs b/build/build.fs index 50879292..3bf22d8e 100644 --- a/build/build.fs +++ b/build/build.fs @@ -448,7 +448,12 @@ let generateAssemblyInfo _ = AssemblyInfo.Title (projectName) AssemblyInfo.Product productName AssemblyInfo.Version latestEntry.AssemblyVersion - AssemblyInfo.Metadata ("ReleaseDate", latestEntry.Date.Value.ToString ("o")) + // The date is optional in a changelog entry, and FAKE 6.1.4 also drops it from the last entry of the file + // (https://github.com/fsprojects/FAKE/blob/6.1.4/src/app/Fake.Core.ReleaseNotes/Changelog.fs#L618), + // which is the latest one while the changelog has a single entry + match latestEntry.Date with + | Some releaseDate -> AssemblyInfo.Metadata ("ReleaseDate", releaseDate.ToString ("o")) + | None -> () AssemblyInfo.FileVersion latestEntry.AssemblyVersion AssemblyInfo.InformationalVersion latestEntry.AssemblyVersion AssemblyInfo.Metadata ("ReleaseChannel", releaseChannel) From 6c5c7fd223ea0698a2c53284d163b113a2ce6948 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 10 Oct 2026 23:32:46 +0200 Subject: [PATCH 09/19] fix(package): describe the package correctly and stamp the assembly version The package described itself as an F# API for Azure Cosmos DB and carried the `AzureCosmosDisableNewtonsoftJsonCheck` property, both left over from the project file this one was copied from. The description is `F# bindings for System.Collections.Immutable` again. `GenerateAssemblyInfo` is switched off for the SDK because the FAKE target of the same name writes `AssemblyInfo.fs`, but the project did not compile that file, so the assembly had version `0.0.0.0`. The generated file is now the first compiled file. Co-Authored-By: Claude Opus 5.5 --- .../AssemblyInfo.fs | 21 +++++++++++++++++++ .../FSharp.Collections.Immutable.fsproj | 5 ++--- 2 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 src/FSharp.Collections.Immutable/AssemblyInfo.fs diff --git a/src/FSharp.Collections.Immutable/AssemblyInfo.fs b/src/FSharp.Collections.Immutable/AssemblyInfo.fs new file mode 100644 index 00000000..e0e99ef0 --- /dev/null +++ b/src/FSharp.Collections.Immutable/AssemblyInfo.fs @@ -0,0 +1,21 @@ +// Auto-Generated by FAKE; do not edit +namespace System +open System.Reflection + +[] +[] +[] +[] +[] +[] +[] +do () + +module internal AssemblyVersionInformation = + let [] AssemblyTitle = "FSharp.Collections.Immutable" + let [] AssemblyProduct = "FSharp.Collections.Immutable" + let [] AssemblyVersion = "1.0.0" + let [] AssemblyFileVersion = "1.0.0" + let [] AssemblyInformationalVersion = "1.0.0" + let [] AssemblyMetadata_ReleaseChannel = "release" + let [] AssemblyMetadata_GitHash = "68ef0c6f4dd8bba13482d36e9d5d20c271c5f294" diff --git a/src/FSharp.Collections.Immutable/FSharp.Collections.Immutable.fsproj b/src/FSharp.Collections.Immutable/FSharp.Collections.Immutable.fsproj index 9f5941f0..6a4bc983 100644 --- a/src/FSharp.Collections.Immutable/FSharp.Collections.Immutable.fsproj +++ b/src/FSharp.Collections.Immutable/FSharp.Collections.Immutable.fsproj @@ -5,14 +5,12 @@ $(AssemblyBaseName) true true - True FSharp.Collections.Immutable FSharp.Collections.Immutable - F# API for using Microsoft Azure Cosmos DB service via NoSQL API - Provides extension methods for the FeedIterator and computation expressions to build operations + F# bindings for System.Collections.Immutable @@ -21,6 +19,7 @@ + From b3c15a43666244a1d8a1f4ffb9564a56e069473b Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 10 Oct 2026 23:32:58 +0200 Subject: [PATCH 10/19] chore(ai): add the F# MCP server FsLangMCP gives agents semantic F# tools: symbol lookup, diagnostics, usages and refactoring previews. It is a local tool pinned in the tool manifest, so `dotnet tool restore` installs it, and `.mcp.json` starts it with `dotnet tool run fslangmcp`. The servers in `.mcp.json` are sorted by name. Co-Authored-By: Claude Opus 5.5 --- .config/dotnet-tools.json | 7 +++++++ .mcp.json | 15 ++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 9d1a9673..1ed8fff1 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -16,6 +16,13 @@ ], "rollForward": false }, + "fslangmcp": { + "version": "0.18.0", + "commands": [ + "fslangmcp" + ], + "rollForward": true + }, "fantomas": { "version": "8.0.5", "commands": [ diff --git a/.mcp.json b/.mcp.json index c6181506..1d9b5d3d 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,12 +1,21 @@ { "servers": { - "Microsoft Docs": { - "type": "http", - "url": "https://learn.microsoft.com/api/mcp" + "F#": { + "type": "stdio", + "command": "dotnet", + "args": [ + "tool", + "run", + "fslangmcp" + ] }, "GitHub": { "type": "http", "url": "https://api.githubcopilot.com/mcp/" + }, + "Microsoft Docs": { + "type": "http", + "url": "https://learn.microsoft.com/api/mcp" } } } From b03c497b4ec0aa7e857dcc1bb9b3c0445efa96c6 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 10 Oct 2026 23:33:30 +0200 Subject: [PATCH 11/19] chore(claude): configure the repository for Claude Code - `CLAUDE.md` imports `.github/copilot-instructions.md`, so GitHub Copilot and Claude Code share one set of instructions; `AGENTS.md` points other agents at the same file. - `.mcp.json` lists the servers for Claude Code under `mcpServers` next to `servers`. - The GitHub MCP server does not support the OAuth dynamic client registration Claude Code needs, so `.claude/scripts/github-mcp-headers.ps1` passes it the token Git Credential Manager already stores. - `ClaudeSkillsLink` links `.claude/skills` to `.github/skills` when that folder exists; the link is local and git-ignored. Co-Authored-By: Claude Opus 5.5 --- .claude/scripts/github-mcp-headers.ps1 | 22 ++++++++++++++++++++++ .gitignore | 3 +++ .mcp.json | 20 ++++++++++++++++++++ AGENTS.md | 3 +++ CLAUDE.md | 5 +++++ Directory.Build.targets | 21 +++++++++++++++++++++ 6 files changed, 74 insertions(+) create mode 100644 .claude/scripts/github-mcp-headers.ps1 create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/.claude/scripts/github-mcp-headers.ps1 b/.claude/scripts/github-mcp-headers.ps1 new file mode 100644 index 00000000..98534f2f --- /dev/null +++ b/.claude/scripts/github-mcp-headers.ps1 @@ -0,0 +1,22 @@ +# Emits GitHub MCP request headers for Claude Code's `headersHelper`. +# Reuses the GitHub OAuth token stored by Git Credential Manager, so no PAT is needed. +# Prints `{}` when no credential is available, so the server stays unauthenticated instead of failing. + +$ErrorActionPreference = 'Stop' +$env:GCM_INTERACTIVE = 'never' +$env:GIT_TERMINAL_PROMPT = '0' + +$token = $null +try { + $credential = "protocol=https`nhost=github.com`n`n" | git credential fill 2>$null + $token = ($credential | Where-Object { $_ -like 'password=*' }) -replace '^password=', '' +} +catch { +} + +if ($token) { + @{ Authorization = "Bearer $token" } | ConvertTo-Json -Compress +} +else { + '{}' +} diff --git a/.gitignore b/.gitignore index 99fd3b7f..7fe30ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -405,3 +405,6 @@ tmp/ temp/ .fsdocs docs/ + +# Local junction to .github/skills, created by Directory.Build.targets +/.claude/skills diff --git a/.mcp.json b/.mcp.json index 1d9b5d3d..2379d355 100644 --- a/.mcp.json +++ b/.mcp.json @@ -17,5 +17,25 @@ "type": "http", "url": "https://learn.microsoft.com/api/mcp" } + }, + "mcpServers": { + "F#": { + "type": "stdio", + "command": "dotnet", + "args": [ + "tool", + "run", + "fslangmcp" + ] + }, + "GitHub": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headersHelper": "pwsh -NoProfile -File .claude/scripts/github-mcp-headers.ps1" + }, + "Microsoft Docs": { + "type": "http", + "url": "https://learn.microsoft.com/api/mcp" + } } } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ae0cbfca --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# Agent Instructions + +Before starting any task, read and follow [.github/copilot-instructions.md](.github/copilot-instructions.md). It holds the full project context and coding standards. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..5dab9eb5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# Claude Code Instructions + +The project instructions are shared with GitHub Copilot and maintained in a single place: + +@.github/copilot-instructions.md diff --git a/Directory.Build.targets b/Directory.Build.targets index cdec8ba6..e69ea9ed 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -18,6 +18,27 @@ See https://learn.microsoft.com/en-us/visualstudio/msbuild/customize-by-director + + + <_ClaudeSkillsLink>$(MSBuildThisFileDirectory).claude/skills + <_ClaudeSkillsTarget>$(MSBuildThisFileDirectory).github/skills + + + + + + + + + - + + + + + + @@ -51,6 +61,7 @@ See https://learn.microsoft.com/en-us/visualstudio/msbuild/customize-by-director +