diff --git a/.claude/scripts/github-mcp-headers.ps1 b/.claude/scripts/github-mcp-headers.ps1 new file mode 100644 index 0000000..98534f2 --- /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 ee9a252..3dfc2bf 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,28 +3,39 @@ "isRoot": true, "tools": { "dotnet-reportgenerator-globaltool": { - "version": "5.3.11", + "version": "5.4.7", "commands": [ "reportgenerator" - ] + ], + "rollForward": false }, "fsharp-analyzers": { - "version": "0.27.0", + "version": "0.31.0", "commands": [ "fsharp-analyzers" - ] + ], + "rollForward": false + }, + "fslangmcp": { + "version": "0.18.0", + "commands": [ + "fslangmcp" + ], + "rollForward": true }, "fantomas": { - "version": "6.3.16", + "version": "8.0.5", "commands": [ "fantomas" - ] + ], + "rollForward": false }, "fsdocs-tool": { "version": "20.0.1", "commands": [ "fsdocs" - ] + ], + "rollForward": false } } } diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 00a2e66..09ddbe3 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,18 +19,18 @@ }, // https://github.com/devcontainers/features/blob/main/src/github-cli/README.md "ghcr.io/devcontainers/features/github-cli:1": {}, - // https://github.com/devcontainers-contrib/features/blob/main/src/starship/README.md - "ghcr.io/devcontainers-contrib/features/starship: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.300", + "version": "10.0", "additionalVersions": "8.0" } }, "overrideFeatureInstallOrder": [ "ghcr.io/devcontainers/features/common-utils", "ghcr.io/devcontainers/features/github-cli", - "ghcr.io/devcontainers-contrib/features/starship", + "ghcr.io/devcontainers-extra/features/starship", "ghcr.io/devcontainers/features/dotnet" ], "customizations": { diff --git a/.editorconfig b/.editorconfig index 2a82eda..39f7479 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, 150 # 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=150 # 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 cb6f883..df5e1a8 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/copilot-instructions.md b/.github/copilot-instructions.md index 7cfcbb7..f646514 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -30,27 +30,62 @@ * [`R3`](https://github.com/Cysharp/R3) – core reactive primitives * [`MSTest`](https://github.com/microsoft/testfx) – test framework * [`Unquote`](https://github.com/SwensenSoftware/unquote) – expressive assertions for complex checks -* [`altcover`](https://github.com/SteveGilham/altcover) – coverage instrumentation in test workflows * [`FAKE`](https://fake.build/) – build and release scripting +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 Preferences +### Language and Tooling * Always use the latest F# 10 features over old syntax. -* Prefer `voption` over `option`. +* 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. -* 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. -* Simplify `Seq.map (fun x -> someFunction x)` to `Seq.map someFunction`. +* 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`, …). This library does not reference `IcedTasks` yet – adding it introduces a transitive dependency for every consumer, so confirm with the maintainers first. + +### 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. -* Prefer interpolated strings over `printf` functions for string formatting. -* Use `withNull` for null checks instead of boxing delegates/functions (avoid `isNull (box value)`). + +### 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 – never for identifiers, which are case-sensitive. +* 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. -* Prefer `match` on `null` over `if isNull`: +* 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 @@ -59,6 +94,41 @@ | 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: @@ -149,6 +219,9 @@ module MyTypeExtensions = * 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 {`. diff --git a/.github/prompts/create-pr-description.prompt.md b/.github/prompts/create-pr-description.prompt.md index f13751b..c98c3c8 100644 --- a/.github/prompts/create-pr-description.prompt.md +++ b/.github/prompts/create-pr-description.prompt.md @@ -39,6 +39,7 @@ Analyze the current branch's git diff and commit history relative to the remote - Use plain English; avoid vague filler phrases like "various improvements". - Do not start the summary with "This branch adds". - Use only en dashes (`–`) for dashes; never use em dashes (`—`). +- Always wrap names and versions into backticks (for example, `ImmutableList`) when referring to them in the description. - Preserve all original template headings, checkbox syntax, and section order exactly. ## Output diff --git a/.github/workflows/publish_ci.yml b/.github/workflows/publish_ci.yml index 2ac854b..33bef3e 100644 --- a/.github/workflows/publish_ci.yml +++ b/.github/workflows/publish_ci.yml @@ -56,7 +56,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 f52e4d4..f31eeed 100644 --- a/.github/workflows/publish_release.yml +++ b/.github/workflows/publish_release.yml @@ -27,7 +27,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/.gitignore b/.gitignore index 99fd3b7..7fe30ed 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 new file mode 100644 index 0000000..2379d35 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,41 @@ +{ + "servers": { + "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" + } + }, + "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 0000000..ae0cbfc --- /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 0000000..5dab9eb --- /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 7841fc4..e69ea9e 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -18,8 +18,40 @@ See https://learn.microsoft.com/en-us/visualstudio/msbuild/customize-by-director - - + + + <_ClaudeSkillsLink>$(MSBuildThisFileDirectory).claude/skills + <_ClaudeSkillsTarget>$(MSBuildThisFileDirectory).github/skills + + + + + + + + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 05b5418..61edca3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,6 +1,5 @@ - @@ -17,8 +16,7 @@ - - + diff --git a/FSharp.Control.R3.slnx b/FSharp.Control.R3.slnx index 08bdcb9..86882e8 100644 --- a/FSharp.Control.R3.slnx +++ b/FSharp.Control.R3.slnx @@ -1,5 +1,6 @@ + diff --git a/README.md b/README.md index 2697bfb..16d43b8 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ FSharp.Control.R3 | [![NuGet Badge](https://buildstats.info/nuget/FSharp.Control Make sure the following **requirements** are installed on your system: -- [dotnet SDK](https://www.microsoft.com/net/download/core) 6.0 or higher +- [dotnet SDK](https://www.microsoft.com/net/download/core) 10.0.401 or higher (uses F# 10) or @@ -40,7 +40,7 @@ or - `CONFIGURATION` will set the [configuration](https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-build?tabs=netcore2x#options) of the dotnet commands. If not set, it will default to Release. - `CONFIGURATION=Debug ./build.sh` will result in `-c` additions to commands such as in `dotnet build -c Debug` -- `ENABLE_COVERAGE` Will enable running code coverage metrics. AltCover can have [severe performance degradation](https://github.com/SteveGilham/altcover/issues/57) so code coverage evaluation are disabled by default to speed up the feedback loop. +- `ENABLE_COVERAGE` Will enable running code coverage metrics with the Microsoft.Testing.Platform code coverage extension (`dotnet test --coverage`). Code coverage evaluation is disabled by default to speed up the feedback loop. - `ENABLE_COVERAGE=1 ./build.sh` will enable code coverage evaluation @@ -76,7 +76,7 @@ src/FSharp.Control.R3/bin/ - `DotnetRestore` - Runs [dotnet restore](https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-restore?tabs=netcore2x) on the [solution file](https://docs.microsoft.com/en-us/visualstudio/extensibility/internals/solution-dot-sln-file?view=vs-2019). - [`DotnetBuild`](#Building) - Runs [dotnet build](https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-build?tabs=netcore2x) on the [solution file](https://docs.microsoft.com/en-us/visualstudio/extensibility/internals/solution-dot-sln-file?view=vs-2019). - `FSharpAnalyzers` - Runs [BinaryDefense.FSharp.Analyzers](https://github.com/BinaryDefense/BinaryDefense.FSharp.Analyzers). -- `DotnetTest` - Runs [dotnet test](https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-test?tabs=netcore21) on the [solution file](https://docs.microsoft.com/en-us/visualstudio/extensibility/internals/solution-dot-sln-file?view=vs-2019). +- `DotnetTest` - Runs [dotnet test](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-test) on each test project with [Microsoft.Testing.Platform](https://learn.microsoft.com/en-us/dotnet/core/testing/microsoft-testing-platform-intro). - `GenerateCoverageReport` - Code coverage is run during `DotnetTest` and this generates a report via [ReportGenerator](https://github.com/danielpalme/ReportGenerator). - `ShowCoverageReport` - Shows the report generated in `GenerateCoverageReport`. - `WatchTests` - Runs [dotnet watch](https://docs.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-3.0) with the test projects. Useful for rapid feedback loops. diff --git a/build/Changelog.fs b/build/Changelog.fs index 89245fe..f3071fa 100644 --- a/build/Changelog.fs +++ b/build/Changelog.fs @@ -60,7 +60,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 e0ee0bc..9b14573 100644 --- a/build/build.fs +++ b/build/build.fs @@ -50,11 +50,10 @@ let distDir = rootDirectory "dist" let distGlob = distDir "*.nupkg" -let coverageThresholdPercent = 0 +let testResultsDir = rootDirectory "TestResults" let coverageReportDir = rootDirectory "docs" "coverage" - let docsDir = rootDirectory "docs" let docsSrcDir = rootDirectory "docsSrc" @@ -146,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 @@ -176,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) = { @@ -242,7 +241,7 @@ let allPublishChecks () = failOnLocalBuild () let disableBinLog (p : MSBuild.CliArguments) = { p with DisableInternalBinLog = true } let clean _ = - [ "bin"; "temp"; distDir; coverageReportDir ] + [ "bin"; "temp"; distDir; coverageReportDir; testResultsDir ] |> Shell.cleanDirs !!srcGlob ++ testsGlob @@ -272,6 +271,8 @@ let updateChangelog ctx = latestEntry <- if not <| isPublishToGitHub ctx then Changelog.updateChangelog changelogPath changelog gitHubRepoUrl ctx + elif Seq.isEmpty changelog.Entries then + latestEntry else let latest = changelog.LatestEntry let semVer = { @@ -326,7 +327,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 -> @@ -344,49 +345,49 @@ let fsharpAnalyzers _ = ) let dotnetTest ctx = - let excludeCoverage = - !!testsGlob - |> Seq.map IO.Path.GetFileNameWithoutExtension - |> 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 - sprintf "/p:AltCover=true" - - if not isGenerateCoverageReport then - sprintf "/p:AltCoverThreshold=%d" coverageThresholdPercent - - sprintf "/p:AltCoverAssemblyExcludeFilter=%s" excludeCoverage - "/p:AltCoverLocalSource=true" + "--coverage" + "--coverage-output-format" + "cobertura" + "--results-directory" + quoted testResultsDir ] - DotNet.test - (fun c -> - - { - c with - MSBuildParams = disableBinLog c.MSBuildParams - Configuration = configuration (ctx.Context.AllExecutingTargets) - Common = c.Common |> DotNet.Options.withAdditionalArgs args - }) - sln + !!testsGlob + |> Seq.iter (fun testProject -> + [ "--project"; quoted testProject; yield! args ] + |> String.concat " " + |> DotNet.exec id "test" + |> failOnBadExitAndPrint + ) let generateCoverageReport _ = - let coverageReports = !! "tests/**/coverage*.xml" |> String.concat ";" + let coverageFiles = + !!(testResultsDir "*.cobertura.xml") + ++ (testResultsDir "*/coverage.cobertura.xml") let sourceDirs = !!srcGlob |> Seq.map Path.getDirectory |> String.concat ";" let independentArgs = [ - sprintf "-reports:\"%s\"" coverageReports + sprintf "-reports:\"%s\"" (coverageFiles |> String.concat ";") sprintf "-targetdir:\"%s\"" coverageReportDir // Add source dir sprintf "-sourcedirs:\"%s\"" sourceDirs - // Ignore Tests and if AltCover.Recorder.g sneaks in - 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 " " @@ -434,7 +435,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 @@ -528,7 +529,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 diff --git a/global.json b/global.json index 1838608..e926bfe 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,10 @@ { "sdk": { - "version": "10.0.300", + "version": "10.0.401", "allowPrerelease": false, "rollForward": "latestMinor" + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } diff --git a/src/FSharp.Control.R3/AsyncObservable.fs b/src/FSharp.Control.R3/AsyncObservable.fs index de9ad8f..81751a5 100644 --- a/src/FSharp.Control.R3/AsyncObservable.fs +++ b/src/FSharp.Control.R3/AsyncObservable.fs @@ -67,7 +67,7 @@ module Observable = /// Maps the given observable with the given asynchronous function let mapAsync (options : ProcessingOptions) (f : 't -> Async<'r>) source = - let selector x ct = ValueTask<'r> (Async.StartImmediateAsTask (f x, ct)) + let selector x ct = ValueTask<'r>(Async.StartImmediateAsTask (f x, ct)) ObservableExtensions.SelectAwait ( source, selector, @@ -139,9 +139,10 @@ module Extensions = |> Async.AwaitTask } - static member toLookup (source, keySelector : 'T -> 'Key, elementSelector : 'T -> 'Element, keyComparer, [] cancellationToken) = async { - let! ct = Async.CancellationToken - return! - ObservableExtensions.ToLookupAsync (source, keySelector, elementSelector, keyComparer = keyComparer, cancellationToken = ct) - |> Async.AwaitTask - } + static member toLookup (source, keySelector : 'T -> 'Key, elementSelector : 'T -> 'Element, keyComparer, [] cancellationToken) = + async { + let! ct = Async.CancellationToken + return! + ObservableExtensions.ToLookupAsync (source, keySelector, elementSelector, keyComparer = keyComparer, cancellationToken = ct) + |> Async.AwaitTask + } diff --git a/src/FSharp.Control.R3/Observable.fs b/src/FSharp.Control.R3/Observable.fs index b47c2ff..b728290 100644 --- a/src/FSharp.Control.R3/Observable.fs +++ b/src/FSharp.Control.R3/Observable.fs @@ -10,7 +10,7 @@ let inline asObservable source : Observable<'Source> = ObservableExtensions.AsOb let inline bind ([] f : 'T -> Observable<'TNext>) source = ObservableExtensions.SelectMany (source, f) /// Converts the elements of the sequence to the specified type -let inline cast<'T, 'CastType> (source) = ObservableExtensions.Cast<'T, 'CastType> (source) +let inline cast<'T, 'CastType> (source) = ObservableExtensions.Cast<'T, 'CastType>(source) /// /// Adds an error handler to an observable sequence. @@ -60,7 +60,7 @@ let inline mapi ([] f : int -> 'T -> 'R) source = ObservableExte /// Merges two observable sequences into one observable sequence let inline merge (source1, source2) = ObservableExtensions.Merge (source1, source2) -let inline ofType<'T, 'R> (source) = ObservableExtensions.OfType<'T, 'R> (source) +let inline ofType<'T, 'R> (source) = ObservableExtensions.OfType<'T, 'R>(source) /// Returns an observable sequence that contains only a single element let inline singleton item = Observable.Return<'T> item diff --git a/src/FSharp.Control.R3/TaskObservable.fs b/src/FSharp.Control.R3/TaskObservable.fs index ba1f738..fe506a7 100644 --- a/src/FSharp.Control.R3/TaskObservable.fs +++ b/src/FSharp.Control.R3/TaskObservable.fs @@ -39,7 +39,7 @@ module Observable = /// Maps the given observable with the given asynchronous function let mapAsync (options : ProcessingOptions) (f : CancellationToken -> 'T -> Task<'R>) source = - let selector x ct = ValueTask<'R> (f ct x) + let selector x ct = ValueTask<'R>(f ct x) ObservableExtensions.SelectAwait ( source, selector, diff --git a/tests/FSharp.Control.R3.Tests/BuilderTests.fs b/tests/FSharp.Control.R3.Tests/BuilderTests.fs index 68f41c0..14e9645 100644 --- a/tests/FSharp.Control.R3.Tests/BuilderTests.fs +++ b/tests/FSharp.Control.R3.Tests/BuilderTests.fs @@ -39,7 +39,7 @@ type BuilderTests () = hasvisited <- true - Assert.AreEqual (4, i) + Assert.AreEqual(4, i) return () } @@ -50,4 +50,4 @@ type BuilderTests () = [ 3..5 ] |> List.iter r3Bus.OnNext // Note: Query will not be awaited, that's why delay. System.Threading.Thread.Sleep 300 - Assert.AreEqual (true, hasvisited) + Assert.AreEqual(true, hasvisited) diff --git a/tests/FSharp.Control.R3.Tests/FSharp.Control.R3.Tests.fsproj b/tests/FSharp.Control.R3.Tests/FSharp.Control.R3.Tests.fsproj index 9c07d08..b8b5d66 100644 --- a/tests/FSharp.Control.R3.Tests/FSharp.Control.R3.Tests.fsproj +++ b/tests/FSharp.Control.R3.Tests/FSharp.Control.R3.Tests.fsproj @@ -2,18 +2,22 @@ Exe net8.0 + true + + + PreserveNewest + + + - - - - + diff --git a/tests/FSharp.Control.R3.Tests/ObservableTests.fs b/tests/FSharp.Control.R3.Tests/ObservableTests.fs index e45d2ce..2aa043a 100644 --- a/tests/FSharp.Control.R3.Tests/ObservableTests.fs +++ b/tests/FSharp.Control.R3.Tests/ObservableTests.fs @@ -25,7 +25,7 @@ type ObservableTests () = let! res = lengthObs - Assert.AreEqual (0, res) + Assert.AreEqual(0, res) } |> Async.StartImmediateAsTask @@ -53,7 +53,7 @@ type ObservableTests () = hasvisited <- true - Assert.AreEqual (4, i) + Assert.AreEqual(4, i) return () } @@ -62,4 +62,4 @@ type ObservableTests () = // Publish some events, "4" should be heard [ 3..5 ] |> List.iter r3Bus.OnNext - Assert.AreEqual (true, hasvisited) + Assert.AreEqual(true, hasvisited) diff --git a/tests/FSharp.Control.R3.Tests/testconfig.json b/tests/FSharp.Control.R3.Tests/testconfig.json new file mode 100644 index 0000000..ba207a0 --- /dev/null +++ b/tests/FSharp.Control.R3.Tests/testconfig.json @@ -0,0 +1,9 @@ +{ + "mstest": { + "parallelism": { + "enabled": true, + "scope": "method", + "workers": 0 + } + } +}