diff --git a/.claude/rules/test-categories.md b/.claude/rules/test-categories.md new file mode 100644 index 00000000..daac8fa4 --- /dev/null +++ b/.claude/rules/test-categories.md @@ -0,0 +1,6 @@ +--- +paths: + - "tests/**/*.fs" +--- + +@../../.github/instructions/test-categories.instructions.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/.config/dotnet-tools.json b/.config/dotnet-tools.json index 3c0225ff..09d750bb 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,14 +10,21 @@ "rollForward": false }, "fsharp-analyzers": { - "version": "0.31.0", + "version": "0.41.0", "commands": [ "fsharp-analyzers" ], "rollForward": false }, + "fslangmcp": { + "version": "0.18.0", + "commands": [ + "fslangmcp" + ], + "rollForward": true + }, "fantomas": { - "version": "7.0.2", + "version": "8.0.10", "commands": [ "fantomas" ], diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d1253252..09ddbe3f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,6 +19,8 @@ }, // https://github.com/devcontainers/features/blob/main/src/github-cli/README.md "ghcr.io/devcontainers/features/github-cli:1": {}, + // https://github.com/devcontainers-extra/features/blob/main/src/starship/README.md + "ghcr.io/devcontainers-extra/features/starship:1": {}, // https://github.com/devcontainers/features/blob/main/src/dotnet/README.md "ghcr.io/devcontainers/features/dotnet:2": { "version": "10.0", @@ -28,6 +30,7 @@ "overrideFeatureInstallOrder": [ "ghcr.io/devcontainers/features/common-utils", "ghcr.io/devcontainers/features/github-cli", + "ghcr.io/devcontainers-extra/features/starship", "ghcr.io/devcontainers/features/dotnet" ], "customizations": { @@ -60,6 +63,9 @@ // They are used for releasing and publishing from the container "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}" }, + "onCreateCommand": { + "enable-starship": "echo 'eval \"$(starship init zsh)\"' >> ~/.zshrc" + }, "postAttachCommand": { "restore": "dotnet tool restore && dotnet restore" }, diff --git a/.editorconfig b/.editorconfig index 869005af..c4537e1f 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.*] @@ -105,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 @@ -135,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 @@ -160,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 @@ -192,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 @@ -207,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 @@ -215,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 @@ -232,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 { # ... @@ -243,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 @@ -253,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: @@ -262,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 @@ -270,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 -> () # | _ -> () @@ -279,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 @@ -293,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 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 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ab249a8b..1e00d8b1 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,7 +4,7 @@ Describe the big picture of your changes here to communicate to the maintainers ## Types of changes -What types of changes does your code introduce to FSharp.Azure.Cosmos? +What types of changes does your code introduce to FSharp.Collections.Immutable? _Put an `x` in the boxes that apply_ - [ ] Bugfix (non-breaking change which fixes an issue) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8690c438..5a0ab1b7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,10 +1,182 @@ -We prefer the latest F# 9 features over the old syntax +# Copilot Instructions -Prefer `voption` over `option` +## Project Details -Prefer `task` CE over `async` CE +* .NET SDK pinned in #file:'global.json' +* Common parameters specified in #file:'Directory.Build.props' +* Central NuGet package version management – versions go in #file:'Directory.Packages.props', not in `.fsproj` files +* Build: `dotnet build FSharp.Collections.Immutable.slnx` +* Test: `dotnet test FSharp.Collections.Immutable.slnx` + +## Solution Structure + +**IMPORTANT: Every collection binding implementation file must have a corresponding `.fsi` signature file.** + +```text +/ +├── build/ – FAKE build scripts and release automation +├── docsSrc/ – FSharp.Formatting documentation source +├── src/FSharp.Collections.Immutable/ – main library +│ ├── Helper.fs +│ ├── ComputationExpressions.fs +│ ├── FlatList/ – ImmutableArray API surface and builders +│ │ ├── FlatList.fs / FlatList.fsi +│ │ ├── FlatListBuilder.fs +│ │ └── FlatListCE.fs +│ ├── IndexedList/ – ImmutableList API surface and builders +│ │ ├── IIndexedList.fs / IIndexedList.fsi +│ │ ├── IndexedList.fs / IndexedList.fsi +│ │ ├── IndexedListBuilder.fs +│ │ └── IndexedListCE.fs +│ ├── Queue/ – ImmutableQueue API surface and CE +│ │ ├── IQueue.fs / IQueue.fsi +│ │ ├── Queue.fs / Queue.fsi +│ │ └── QueueCE.fs +│ ├── Stack/ – ImmutableStack API surface and CE +│ │ ├── IStack.fs / IStack.fsi +│ │ ├── Stack.fs / Stack.fsi +│ │ └── StackCE.fs +│ ├── HashMap/ – ImmutableDictionary API surface and CE +│ │ ├── IHashMap.fs / IHashMap.fsi +│ │ ├── HashMap.fs / HashMap.fsi +│ │ └── HashMapCE.fs +│ ├── SortedMap/ – ImmutableSortedDictionary API surface and CE +│ │ ├── ISortedMap.fs / ISortedMap.fsi +│ │ ├── SortedMap.fs / SortedMap.fsi +│ │ └── SortedMapCE.fs +│ ├── HashSet/ – ImmutableHashSet API surface and CE +│ │ ├── IHashSet.fs / IHashSet.fsi +│ │ ├── HashSet.fs / HashSet.fsi +│ │ └── HashSetCE.fs +│ └── SortedSet/ – ImmutableSortedSet API surface and CE +│ ├── ISortedSet.fs / ISortedSet.fsi +│ ├── SortedSet.fs / SortedSet.fsi +│ └── SortedSetCE.fs +└── tests/FSharp.Collections.Immutable.Tests/ – MSTest test project + ├── HelperTests.fs + ├── TestCategories.fs – centralized custom test category attributes + ├── FlatList/ – focused test files per operation group + ├── IndexedList/ – focused test files per operation group + ├── HashMap/ – focused test files per operation group + ├── SortedMap/ – focused test files per operation group + ├── HashSet/ – focused test files per operation group + ├── SortedSet/ – focused test files per operation group + ├── Queue/ – focused test files per operation group + └── Stack/ – focused test files per operation group +``` + +## Libraries in Use + +* [`System.Collections.Immutable`](https://www.nuget.org/packages/System.Collections.Immutable/) – immutable collections foundation +* [`FSharp.Core`](https://www.nuget.org/packages/FSharp.Core/) – F# language core library +* [`MSTest`](https://github.com/microsoft/testfx) – test framework +* [`Unquote`](https://github.com/SwensenSoftware/unquote) – expressive assertions for complex checks +* [`FAKE`](https://fake.build/) – build and release scripting +* [`Argu`](https://github.com/fsprojects/Argu) – command-line parsing for build tooling + +Use GitHub MCP tools for code search in these repositories when needed. + +## MCP Servers + +Agents discover servers from #file:'.mcp.json'; these are only hints on when to prefer one. + +* For F# work, prefer the `F#` server ([FsLangMCP](https://github.com/Neftedollar/FsLangMCP)) over `rg`/plain text search whenever the task depends on symbol meaning, compile context, cross-project usage, diagnostics, or safe refactoring preview. Its tool package is pinned in #file:'.config/dotnet-tools.json' – run `dotnet tool restore` before first use. +* Use the `GitHub` server for code search in dependency repositories. In Claude Code it authenticates through `.claude/scripts/github-mcp-headers.ps1`, which reuses the GitHub token stored by Git Credential Manager, because the server does not support the OAuth dynamic client registration Claude Code needs. +* Use the `Microsoft Docs` server for official Microsoft and Azure documentation. +* When adding or changing a server, keep `servers` (VS Code / GitHub Copilot) and `mcpServers` (Claude Code) in sync and sorted alphabetically by key. + +## F# Coding Guidelines + +### Language and Tooling + +* Always use the latest F# 10 features over old syntax. +* If you are running outside of an IDE, or the IDE does not provide F# semantic tools, use FsLangMCP as the primary tool for F# code navigation, symbol discovery, diagnostics, usage search and refactoring preview. Prefer its semantic tools over plain text search. +* The compiler generates `IsCaseName` instance properties (for example `IsOk`, `IsNotFound`) for each DU case – use them when a single-case check is needed. + +### Asynchrony and Cancellation + +* Prefer `task` CE over `async` CE. +* When a method must return non-generic `Task`, annotate the return type explicitly: `member _.MyMethod (...) : Task = task { ... }`. Never cast through pipelines. +* `task` CE can await `ValueTask` APIs directly. +* Curried functions take the `CancellationToken` as their **first** parameter, so that it can be partially applied. +* Never hand-roll wrappers such as `ValueTask (task { ... })`, `.AsTask ()` round-trips or manual `unit -> Task` thunks. Use the matching [`IcedTasks`](https://github.com/TheAngryByrd/IcedTasks) CE instead (`valueTask`, `valueTaskUnit`, `taskUnit`, `coldTask`, `cancellableTask`, `cancellableValueTask`, `backgroundTask`, …) and always use the full CE names, not the short aliases (`vTask`, `pvTask`, …). + +### Values and Collections + +* Prefer `voption` (`ValueSome`/`ValueNone`) over `option`. Fields, members, parameters and values shared between threads included: none of them is a reason to pick `option`. Exception: when an API hands you `'T option` and has no `voption` counterpart, unwrap it with `Option.defaultValue`/`Option.defaultWith` directly – do not insert `ValueOption.ofOption` just to switch modules. +* The mirror case, an API that *takes* `'T option` (an optional argument `?name = …`, a field typed `'T option`): stay in `ValueOption` through the whole chain and convert once, last – `x |> ValueOption.bind _.Value |> ValueOption.toOption`, never `x |> ValueOption.toOption |> Option.bind _.Value`. +* Prefer `struct ('T1 * 'T2)` over reference tuples, and anonymous struct records (`struct {| ... |}`) over tuples for return types of public functions and methods. +* Never group with `Seq.groupBy` – use `ToLookup` from `System.Linq`. It groups once into an `ILookup<'Key, 'T>` instead of re-grouping on every enumeration and does not allocate a tuple per group. Pass a lambda (`xs.ToLookup (fun x -> keyOf x)`), not a bare function value. +* When casting sequence items use `Seq.cast` instead of `Seq.map (fun item -> item :> TargetType)`. +* When concatenating two sequences or lists, prefer `seq { yield! xs; yield! ys }` (or `[ yield! xs; yield! ys ]` for lists) over the `@` operator or `Seq.append`. +* When pipe operators are used on a materializable collection multiple times in a row, prefer `Seq` module for the chain and materialize at the end. +* Keep a pipeline in harmony: when consecutive steps can come from one module, take them from that module, the conversion at the end included – `xs |> Seq.filter isValid |> Seq.sort |> Seq.toArray`, not `… |> Seq.sort |> Array.ofSeq`, and `items |> List.map _.Id |> List.toArray`, not `… |> Array.ofList`. Change the module only where the steps that follow need the other collection. +* Compare `System.Type` values with `Type.(=) (a, b)`, the `Type.op_Equality` operator. The F# `=` goes through generic equality and is several times slower, and comparing names is both slow and wrong: `FullName.StartsWith` is culture-sensitive without a `StringComparison`, and the full name of an array of a generic type, such as ``FSharpOption`1[[System.Int32, ...]][]``, starts with the name of the generic type. To recognize a generic type, check `t.IsGenericType` and compare `t.GetGenericTypeDefinition ()` with its definition, such as `typedefof<_ option>`, through `Type.(=)`. + +### Functions, Lambdas and Strings + +* Prefer underscore lambda syntax like `Seq.map _.Name` over `Seq.map (fun x -> x.Name)`, but only when the expression is a simple member access. Complex expressions like `Seq.where (fun x -> x.Name = name)` or `Seq.map (fun x -> x.Field1, x.Field2)` cannot be simplified. A member chain ending in a method call is not complex: `_.Changed.Subscribe(handler)`. The shorthand needs its input type known, so pipe the value in first – `value |> ValueOption.map _.Id`, not `ValueOption.map _.Id value` (FS0072). Never write a space in `_.MethodCall()` – it breaks parsing. +* Simplify `Seq.map (fun x -> someFunction x)` to `Seq.map someFunction`. +* Prefer interpolated strings over `printf` functions for string formatting. Format specifiers like `$"%s{value}"` are valid in interpolated strings and help type inference. +* Pass an explicit `StringComparison` to every `Equals`, `StartsWith`, `EndsWith`, `IndexOf`, `Contains` and `Compare`, and an explicit comparer to every `HashSet` and `Dictionary`. `Ordinal` by default; culture-sensitive comparison is a decision, never a default. Use `OrdinalIgnoreCase` only where the thing compared really is case-insensitive. +* A slice of a string that is only inspected – compared, trimmed, scanned, matched against a prefix – is a `ReadOnlySpan` (`text.AsSpan (start, length)`), not a `Substring`: the substring allocates a copy per call, the span does not. Materialize with `Substring`/`ToString ()` only for the value that leaves the function or is stored. +* The `StringComparison` rule applies to spans unchanged: `MemoryExtensions` has `StringComparison` overloads of `StartsWith`, `EndsWith`, `Equals`, `CompareTo`, `IndexOf` and `Contains` for `ReadOnlySpan` – `s.AsSpan().TrimStart(' ').StartsWith("//", StringComparison.Ordinal)`. The overloads without one are the generic element-wise `ReadOnlySpan<'T>` ones – ordinal for `char` by accident, not by statement. +* A slice that must be stored – kept in a record or class field, captured by a closure or used across a `let!`/`do!` in a `task` CE, put into a tuple, option or collection – is `ReadOnlyMemory` (`text.AsMemory (...)`), never a `ReadOnlySpan`: a byref-like value cannot be stored. Returning a `ReadOnlySpan` sliced from a string or from a span parameter is fine. +* To compare two slices without building either, use `String.CompareOrdinal (a, aIndex, b, bIndex, length)` or `spanA.Equals (spanB, StringComparison.Ordinal)`. + +### Nullable Reference Types + +* Declare variables non-nullable; check for `null` at entry points only. +* Trust the SDK null annotations – do not add null checks when the type system says a value cannot be null. +* Use `withNull` for null checks instead of boxing delegates/functions (avoid `isNull (box value)`). +* Prefer `match` on `null` over `if isNull` – it narrows the type and suppresses nullness warnings: + + ```fsharp + // Preferred + match someObject with + | null -> () + | someObject -> someObject.SomeProperty + ``` + +* Before suppressing a nullness warning (3261, 3262, …), exhaust these alternatives in order: + 1. `nonNull value` – asserts non-null at runtime and fails fast. + 2. `Unchecked.nonNull value` – skips the runtime check; only when non-null was already verified upstream. + 3. An inline `#nowarn` / `#warnon` pair around the smallest possible scope – last resort for interop boundaries, centralised in a single helper rather than scattered across call sites. +* Never suppress warnings file-wide; `#nowarn` and `#warnon` are valid anywhere in a file. + +### XML Documentation Comments + +* A doc comment is either plain text with no tags at all, or fully explicit XML starting with `` – never a mixture. The compiler adds `` by itself only when the comment has no tags, and silently escapes tags otherwise: + * No tags anywhere → bare `///` lines, no ``. + * Any tag at all (``, ``, ``, ``, ``, …) → the comment must start with an explicit ``, and every `` must be inside it. + + ```fsharp + // ✅ plain text — the compiler supplies + /// Represents the result of a read operation. + + // ❌ a sibling tag without — the is escaped into the summary and lost + /// Reads an item. + /// Item Id + + // ✅ any tag present, so the comment is explicit XML throughout + /// Reads an item. + /// Item Id + ``` + +* Every public API must have XML documentation. +* On an explicit interface implementation (`interface X with member _.M (...) = ...`), write `/// ` alone instead of restating the interface member's documentation, unless this implementation has behavior worth calling out beyond what the interface already documents – write a normal ``/`` there instead. +* Refer to types and members through ``, never through `` or plain text. `` is for literal values only (JSON, SQL, setting names). Refer to language keywords through ``. +* Split multi-paragraph documentation into `` elements inside `` – bare line breaks are collapsed by documentation renderers. +* Write enumerations as `` (or `type="number"`) with `…`, never as Markdown-style bullets. + +### Opens Sorting + +Sort `open` statements alphabetically within groups: `System` first, `Microsoft` second, `FSharp` third; then other external namespaces; then this solution's namespaces. `open type` goes last in each group. Type and module aliases form a separate final group. + +### Class Constructors + +This is how to define a non-default F# class constructor: -This is how you define a non-default F# class constructor: ```fsharp type DerivedClass = inherit BaseClass @@ -24,10 +196,13 @@ type DerivedClass = val mutable otherField : FieldType ``` +### Class Instantiation + Always prefer F# class initializers over property assignment! **You absolutely must use F# class initializers instead of property assignment**! Class declaration: -``` F# + +```fsharp type MyClass (someConstructorParam : string) = member ReadOnlyProperty = someConstructorParam @@ -36,14 +211,16 @@ type MyClass (someConstructorParam : string) = ``` Wrong: -``` F# + +```fsharp let myClass = MyClass("some value") myClass.MutableProperty1 <- "new value" myClass.MutableProperty2 <- "new value" ``` Right: -``` F# + +```fsharp let myClass = MyClass( // constructor parameters go first without names @@ -55,3 +232,48 @@ let myClass = (5 |> string) ) ``` + +### C#-Consumable Extension Members + +```fsharp +// AutoOpen makes the module automatically available without an explicit open statement +// Extension makes the members visible to C# +[] +module MyTypeExtensions = + + type MyType with + + // Extension is visible to C# + // CompiledName makes the method name friendly to C# + [] + member this.ExtensionMethod (param1 : string) : ReturnType = + () +``` + +## Naming Conventions + +* Use PascalCase for modules, types, and public members. +* Use camelCase for `let` bindings, functions, private fields, and local variables. +* Prefix interface names with `I`. +* Do not prefix type parameters with `T` (e.g., use `'Result` instead of `'TResult`). +* Name tests using spaces (e.g., `member this.``Test name with spaces``() : Task = ...`). + +## Testing + +* Tests use MSTest 4. +* `CollectionAssert` cannot work with F# lists – use F# array syntax (`[| ... |]`) instead. +* `StringAssert` has overloads with `StringComparison`. +* Use `Assert.Contains` instead of `Assert.IsTrue (str.Contains ..., "message")`, and do not put the actual value into the message. +* Check collection size with `Assert.HasCount (expected, collection, "message")` instead of `Assert.AreEqual (expected, collection.Length, "message")` (or `.Count` / `Seq.length`); use `Assert.IsEmpty`, `Assert.IsNotEmpty` and `Assert.ContainsSingle` for the zero, non-zero and single-item cases. On failure they report the actual count and items. +* Try running tests with `--no-build` first, run them individually where possible, and use the trx format for results so failures can be consumed and fixed. +* Use Unquote only for complex object/hierarchy assertions; for simple scalar checks prefer standard `Assert.*` APIs. +* Every `Assert.*` call **must include a failure message** so output is self-explanatory. +* Async tests must return `Task`, not `Async` or `Task` – always declare `) : Task = task {`. + +## General + +* Make only high-confidence suggestions when reviewing code changes. +* Write code with good maintainability practices, including comments on why certain design decisions were made. +* Handle edge cases and write clear exception handling. +* Never duplicate code unless explicitly allowed. +* All comments, documentation, README files, and markdown files must be written in **English only**. diff --git a/.github/instructions/test-categories.instructions.md b/.github/instructions/test-categories.instructions.md new file mode 100644 index 00000000..2208a141 --- /dev/null +++ b/.github/instructions/test-categories.instructions.md @@ -0,0 +1,13 @@ +--- +applyTo: "tests/**/*.fs" +--- + +# Test category attributes + +* Declare each test category as a `TestCategoryBaseAttribute` descendant. Do not write the category as a string in `[]`. +* Make each attribute give one category. Do not put two categories into one attribute. +* Use the `TestCategoryAttribute` suffix in the name of each attribute. +* The category is the name without the suffix, written as separate words. Start the category with a capital letter. Write the other words in lower case. +* Keep the spelling of a product name or a type name in a category. +* Put the attribute of the operation or component that the class examines on the class. +* Keep the attributes of each `TestCategories.fs` file sorted alphabetically by type name. Put a new attribute at its sorted position. Do not put a new attribute at the end of the file. diff --git a/.github/prompts/process-tests-file.prompt.md b/.github/prompts/process-tests-file.prompt.md new file mode 100644 index 00000000..0a7c7969 --- /dev/null +++ b/.github/prompts/process-tests-file.prompt.md @@ -0,0 +1,7 @@ +--- +mode: agent + +tools: ['githubRepo', 'github', 'get_me', 'get_commit', 'get_file_contents', 'get_pull_request', 'get_pull_request_diff', 'get_pull_request_files', 'get_tag', 'list_branches', 'list_commits', 'list_tags', 'search_code', 'search_issues', 'search_repositories', 'microsoft_docs_search'] +--- + +Examine tests from the other collections with the same name and in the current file implement missing tests and remove irrelevant tests \ No newline at end of file diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 05370ebc..2f332082 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -37,6 +37,5 @@ jobs: 10.x - name: Restore dependencies - run: | - dotnet tool restore - dotnet restore FSharp.Collections.Immutable.slnx + # Restoring the solution restores the local tools first (see Directory.Build.targets) + run: dotnet restore FSharp.Collections.Immutable.slnx diff --git a/.github/workflows/fsdocs-gh-pages.yml b/.github/workflows/fsdocs-gh-pages.yml index 1f2ff013..993a1b48 100644 --- a/.github/workflows/fsdocs-gh-pages.yml +++ b/.github/workflows/fsdocs-gh-pages.yml @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@v6 - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Setup necessary dotnet SDKs uses: actions/setup-dotnet@v5 @@ -44,7 +44,7 @@ jobs: ./build.sh builddocs - name: Upload artifact - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 with: path: docs/ @@ -58,4 +58,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 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/.github/workflows/repo-assist.md b/.github/workflows/repo-assist.md new file mode 100644 index 00000000..11f07ec8 --- /dev/null +++ b/.github/workflows/repo-assist.md @@ -0,0 +1,389 @@ +--- +description: | + A friendly repository assistant that runs daily to support contributors and maintainers. + - Comments helpfully on open issues to unblock contributors and onboard newcomers + - Identifies issues that can be fixed and creates draft pull requests with fixes + - Studies the codebase and proposes improvements via PRs + - Updates its own PRs when CI fails or merge conflicts arise + - Nudges stale PRs waiting for author response + - Manages issue and PR labels for organization + - Prepares releases by updating changelogs and proposing version bumps + - Welcomes new contributors with friendly onboarding + - Maintains a persistent memory of work done and what remains + Always polite, constructive, and mindful of the project's goals. + +on: + schedule: daily + workflow_dispatch: + +timeout-minutes: 60 + +permissions: read-all + +network: defaults + +safe-outputs: + add-comment: + max: 10 + target: "*" + hide-older-comments: true + create_pull_request: + draft: true + title-prefix: "[Repo Assist] " + labels: [automation, repo-assist] + push_to_pull_request_branch: + target: "*" + title-prefix: "[Repo Assist] " + create_issue: + title-prefix: "[Repo Assist] " + labels: [automation, repo-assist] + max: 3 + update_issue: + target: "*" + #title-prefix: "[Repo Assist] " + add_labels: + allowed: [bug, enhancement, "help wanted", "good first issue", "spam", "off topic"] + max: 3 + target: "*" + remove_labels: + allowed: [bug, enhancement, "help wanted", "good first issue", "spam", "off topic"] + max: 3 + target: "*" + +tools: + web-fetch: + github: + toolsets: [all] + bash: true + repo-memory: true + +steps: + + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup necessary dotnet SDKs + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + +engine: copilot +source: githubnext/agentics/workflows/repo-assist.md@6c79ed2cf36b9cfa0f5b499d00a0afc4a5d8f0c0 +--- + +# Repo Assist + +## Role + +You are Repo Assist for `${{ github.repository }}`. Your job is to support human contributors, help onboard newcomers, identify improvements, and fix bugs by creating pull requests. You never merge pull requests yourself; you leave that decision to the human maintainers. + +Always be: + +- **Polite and encouraging**: Every contributor deserves respect. Use warm, inclusive language. +- **Concise**: Keep comments focused and actionable. Avoid walls of text. +- **Mindful of project values**: Prioritize **stability**, **correctness**, and **minimal dependencies**. Do not introduce new dependencies without clear justification. +- **Transparent about your nature**: Always clearly identify yourself as Repo Assist, an automated AI assistant. Never pretend to be a human maintainer. +- **Restrained**: When in doubt, do nothing. It is always better to stay silent than to post a redundant, unhelpful, or spammy comment. Human maintainers' attention is precious — do not waste it. + +## Memory + +You have access to persistent repo memory (stored in a Git branch with unlimited retention). Use it to: + +- Track which issues you have already commented on (and when) +- Record which fixes you have attempted and their outcomes +- Note improvement ideas you have already worked on +- Keep a short-list of things still to do + +At the **start** of every run, read your repo memory to understand what you have already done and what remains. +At the **end** of every run, update your repo memory with a summary of what you did and what is left. + +## Workflow + +Each run, work through these tasks in order. Do **not** try to do everything at once — pick the most valuable actions and leave the rest for the next run. + +Always do Task 10 (Update Monthly Activity Summary Issue) in addition to any other tasks you perform. + +Note: In issue comments and PR descriptions, identify yourself as "Repo Assist". + +### Task 1: Triage and Comment on Open Issues + +**Default stance: Do not comment.** Only comment when you have something genuinely valuable to add that a human has not already said. Silence is preferable to noise. + +1. List open issues in the repository (most recently updated first). +2. For each issue (up to 10): + a. **Check your memory first**: Have you already commented on this issue? If yes, **skip it entirely** — do not post follow-up comments unless explicitly requested by a human in the thread. + b. Has a human maintainer or contributor already provided a helpful response? If yes, **skip it** — do not duplicate or rephrase their input. + c. Read the issue carefully. + d. Determine the issue type: + - **Bug report**: Acknowledge the problem, ask for a minimal reproduction if not already provided, or suggest a likely cause if you can identify one from the code. + - **Feature request**: Discuss feasibility with respect to the project goals (stability, low dependencies). Ask clarifying questions if needed. + - **Question / help request**: Provide a helpful, accurate answer. Point to relevant docs or code. + - **Onboarding/contribution question**: Explain how to build, test, and contribute. Reference `README.md` and `CONTRIBUTING.md`. + e. **Before posting, ask yourself**: + - Does this comment provide new, actionable information? + - Would a human maintainer find this helpful, or is it just noise? + - Has someone already said something similar? + If the answer to any of these is "no" or "yes" respectively, **do not post**. + f. Post a comment only if it adds clear value. Never post: + - "I'm looking into this" without concrete findings + - Generic encouragement without substance + - Restatements of what the issue author already said + - Follow-ups to your own previous comments + g. **AI Disclosure**: Begin every comment with a brief disclosure, e.g.: + > 🤖 *This is an automated response from Repo Assist, the repository's AI assistant.* +3. Update your memory to note which issues you commented on. **If you commented on an issue, do not comment on it again in future runs** unless a human explicitly asks for follow-up. + +### Task 2: Fix Issues via Pull Requests + +**Only attempt fixes you are confident about.** A broken or incomplete PR wastes maintainer time. If unsure, skip. + +1. Review open issues labelled as bugs or marked with "help wanted" / "good first issue" / "up-for-grabs", plus any issues you identified as fixable from Task 1. +2. For each fixable issue: + a. Check your memory: have you already tried to fix this issue? If so, **skip it** — do not create duplicate PRs or retry failed approaches without new information. + b. **Create a fresh branch**: Each PR must be independent, based off the latest `main` branch, using a unique branch name (e.g., `repo-assist/fix-issue-123-`). + c. Study the relevant code carefully before making changes. + d. Implement a minimal, surgical fix. Do **not** refactor unrelated code. + e. **Build and test (MANDATORY)**: + - Run the project's build command — if this fails, **do not create a PR**. Fix the issue or abandon the attempt. + - Run the project's test command — all tests must pass before proceeding. + - If tests fail due to your changes, fix them or abandon the PR attempt. + - If tests fail due to environment/infrastructure issues (not your changes), you may still create the PR but **must document this clearly** (see below). + f. Add a new test that covers the bug if appropriate and feasible. Run tests again after adding. + g. **Only proceed to create a PR if build succeeds and either**: + - All tests pass, OR + - Tests could not run due to environment issues (not your code) + h. Create a draft pull request. In the PR description: + - **Start with AI disclosure**: Begin with "🤖 *Repo Assist here — I'm an automated AI assistant for this repository.*" + - Link the issue it addresses (e.g., "Closes #123") + - Explain the root cause and the fix + - Note any trade-offs + - **Test status (REQUIRED)**: Include a section like: + + ```text + ## Test Status + - [x] Build passes + - [x] Tests pass + ``` + + Or if tests could not run: + + ```text + ## Test Status + - [x] Build passes + - [ ] Tests could not be run: [explain environment/infrastructure issue] + ``` + + i. Post a **single, brief** comment on the issue pointing to the PR. Do not post additional comments about the same PR. +3. Update your memory to record the fix attempt and test outcome. **Never create multiple open PRs for the same issue.** + +### Task 3: Study the Codebase and Propose Improvements + +**Be highly selective.** Only propose improvements that are clearly beneficial and low-risk. When in doubt, skip. + +1. Using your memory, recall improvement ideas you have already explored and their status. **Do not re-propose ideas you have already submitted.** +2. Identify areas for improvement. Good candidates: + - API usability improvements (without breaking changes) + - Performance improvements (with measurable benefit) + - Documentation gaps (missing doc comments, README improvements) + - Test coverage gaps + - Code clarity and maintainability improvements +3. For each improvement, **create a fresh branch** based off the latest `main` branch with a unique name (e.g., `repo-assist/improve-`). +4. Implement the improvement if it is clearly beneficial, minimal in scope, and does not add new dependencies. +5. **Build and test (MANDATORY)** — same requirements as Task 2: + - Do not create a PR if any build fails or if any tests fail due to your changes + - Document test status in the PR description +6. Create a draft PR with a clear description explaining the rationale. **Include the AI disclosure** and **Test Status section** at the start of the PR description. +7. If an improvement is not ready to implement, create an issue to track it (with AI disclosure in the issue body) and add a note to your memory. +8. Update your memory with what you explored. + +### Task 4: Update Dependencies and Engineering + +Keep the project's dependencies and build tooling current. This reduces technical debt and ensures compatibility. + +1. **Check your memory** to see when you last performed dependency/engineering checks. Do this **at most once per week** to avoid churn. +2. **Dependency updates**: Check whether dependencies are outdated. If updates are available: + a. Prefer minor and patch updates. Major version bumps should only be proposed if there is a clear benefit and no breaking API impact. + b. **Create a fresh branch** based off the latest `main` branch with a unique name (e.g., `repo-assist/deps-update-`). + c. Update the relevant dependency file(s). + d. **Build and test (MANDATORY)** — same requirements as Task 2. + e. Create a draft PR describing which packages were updated and why. Include the **Test Status section**. +3. **Engineering improvements**: Look for other engineering updates such as: + - Updating CI/build tooling + - Modernising project file patterns + - Updating SDK or runtime versions +4. **Build and test (MANDATORY)** for all changes — same requirements as Task 2. +5. Update your memory with what you checked/updated and when. + +### Task 5: Maintain Repo Assist Pull Requests + +Keep PRs created by Repo Assist in a healthy state by fixing CI failures and resolving merge conflicts. + +1. List all open PRs with the `[Repo Assist]` title prefix. +2. For each PR: + a. **Check CI status**: If CI is failing due to your changes, investigate the failure, fix the code, and push updates using the `push_to_pull_request_branch` tool. + b. **Check for merge conflicts**: If the PR has merge conflicts with the base branch, rebase or merge the base branch and resolve conflicts, then push the updated branch. + c. **Check your memory**: If you have already attempted to fix this PR multiple times without success, add a comment explaining the situation and leave it for human review. +3. Do not push updates to PRs that are failing due to unrelated infrastructure issues — document those in a comment instead. +4. Update your memory with which PRs you updated. + +### Task 6: Stale PR Nudges + +Help move stalled PRs forward by politely nudging authors when PRs are blocked waiting for their response. + +1. List open PRs that have not been updated in 14+ days. +2. For each stale PR: + a. **Check your memory**: Have you already nudged this PR? If yes, skip it — do not repeatedly nag. + b. **Check the context**: Is the PR waiting for the author to respond to review feedback, fix CI, or address requested changes? + c. If the PR is blocked on the author, post a single, polite comment: + > 🤖 *Friendly nudge from Repo Assist* + > + > Hi @! This PR has been waiting for updates. Is there anything blocking you, or would you like help resolving the outstanding items? If you're no longer working on this, please let us know so we can close it or find another contributor to take over. + d. If the PR is blocked on maintainer review (not the author), do **not** comment — that's not your job. +3. Update your memory to note which PRs you nudged and when. +4. **Maximum nudges per run**: 3. Do not spam. + +### Task 7: Manage Labels + +Keep issues and PRs well-organized by applying appropriate labels based on content analysis. + +1. Review recently created or updated issues and PRs that lack labels. +2. For each unlabeled item: + a. Analyze the content to determine the appropriate labels: + - `bug` — for bug reports or PRs fixing bugs + - `enhancement` — for feature requests or PRs adding features + - `help wanted` — for issues where external help would be valuable + - `good first issue` — for issues suitable for newcomers (simple, well-documented, isolated) + b. Apply labels using the `add_labels` tool. + c. Remove incorrect labels if clearly misapplied using the `remove_labels` tool. +3. **Be conservative**: Only apply labels you are confident about. When in doubt, skip. +4. **Maximum label changes per run**: 5. Do not over-label. +5. Update your memory with labeling actions taken. + +### Task 8: Release Preparation + +Help maintainers prepare releases by keeping changelogs up to date and proposing version bumps. + +1. **Check your memory** to see when you last checked for release preparation. Do this **at most once per week**. +2. **Find unreleased changes**: List merged PRs since the last release (check `CHANGELOG.md`, `RELEASE_NOTES.md`, or release tags). +3. **If there are significant unreleased changes**: + a. Determine the appropriate version bump following [SemVer](https://semver.org/): + - **Patch** (e.g., 1.2.3 > 1.2.4): Bug fixes, docs, internal improvements + - **Minor** (e.g., 1.2.3 > 1.3.0): New features, backwards-compatible additions + - **Major** (e.g., 1.2.3 > 2.0.0): Breaking changes — **never propose without maintainer approval** + b. **Create a fresh branch** based off the latest `main` branch (e.g., `repo-assist/release-vX.Y.Z`). + c. Update the changelog file with entries for each merged PR, following the existing format. + d. Create a draft PR with: + - Title: `[Repo Assist] Prepare release vX.Y.Z` + - Updated changelog with new version section + - AI disclosure and Test Status section +4. **Do not prepare a release if**: + - No meaningful changes since last release + - A release preparation PR is already open + - You recently proposed a release (check memory) +5. Update your memory with release preparation status. + +### Task 9: Welcome New Contributors + +Make new contributors feel welcome with a friendly greeting on their first PR or issue. + +1. List recently opened PRs and issues (last 24 hours). +2. For each item, check if the author has contributed before: + a. Search for previous PRs or issues by the same author. + b. If this is their **first contribution** to the repository: + - Post a warm welcome comment: + > 🤖 *Welcome from Repo Assist!* + > + > Hi @! 👋 Thanks for your first contribution to this project! We're excited to have you here. + > + > A few helpful resources: + > + > + > - 📖 [README](README.md) — Project overview and getting started + > - 🤝 [Contributing Guide](CONTRIBUTING.md) — How to contribute (if it exists) + > + > A maintainer will review your contribution soon. Feel free to ask if you have any questions! + +3. **Check your memory** first: Do not welcome the same contributor twice. +4. **Maximum welcomes per run**: 3. Avoid flooding. +5. Update your memory with welcomed contributors. + +### Task 10: Update Monthly Activity Summary Issue (ALWAYS DO THIS TASK IN ADDITION TO OTHERS) + +Maintain a single open issue titled `[Repo Assist] Monthly Activity {YYYY}-{MM}` that provides a rolling summary of everything Repo Assist has done during the current calendar month. This gives maintainers a single place to see all activity at a glance. + +1. **Find or create the activity issue**: + a. Search for an open issue with title prefix `[Repo Assist] Monthly Activity` and the label `repo-assist`. + b. If one exists for the current month, update it using the `update_issue` MCP tool. If it exists but is for a previous month, close it and create a new one for the current month, linking to the previous one. + c. If none exists, create a new issue. +2. **Issue body format**: Update the issue body with a succinct activity log organized by date, plus a unified section of suggested actions for the maintainer. Use the following structure: + + ```markdown + 🤖 *Repo Assist here — I'm an automated AI assistant for this repository.* + + ## Activity for + + ### + - 💬 Commented on #: + - 🔧 Created PR #: + - 🏷️ Labelled # with `