From 1b877b02a05c4af0729f06bee7affd892d9bbf02 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Sun, 8 Feb 2026 20:27:59 +0100 Subject: [PATCH 01/21] fsharp-diagnostics skill added --- .github/skills/fsharp-diagnostics/SKILL.md | 51 ++++++ .../scripts/get-fsharp-errors.sh | 102 ++++++++++++ .../server/DesignTimeBuild.fs | 67 ++++++++ .../server/DiagnosticsFormatter.fs | 37 +++++ .../server/Directory.Build.props | 9 ++ .../server/FSharpDiagServer.fsproj | 21 +++ .../fsharp-diagnostics/server/Program.fs | 31 ++++ .../server/ProjectManager.fs | 50 ++++++ .../fsharp-diagnostics/server/Server.fs | 147 ++++++++++++++++++ 9 files changed, 515 insertions(+) create mode 100644 .github/skills/fsharp-diagnostics/SKILL.md create mode 100755 .github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh create mode 100644 .github/skills/fsharp-diagnostics/server/DesignTimeBuild.fs create mode 100644 .github/skills/fsharp-diagnostics/server/DiagnosticsFormatter.fs create mode 100644 .github/skills/fsharp-diagnostics/server/Directory.Build.props create mode 100644 .github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj create mode 100644 .github/skills/fsharp-diagnostics/server/Program.fs create mode 100644 .github/skills/fsharp-diagnostics/server/ProjectManager.fs create mode 100644 .github/skills/fsharp-diagnostics/server/Server.fs diff --git a/.github/skills/fsharp-diagnostics/SKILL.md b/.github/skills/fsharp-diagnostics/SKILL.md new file mode 100644 index 00000000000..982d52da693 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/SKILL.md @@ -0,0 +1,51 @@ +--- +name: fsharp-diagnostics +description: "Get F# compiler errors/warnings for src/Compiler/FSharp.Compiler.Service.fsproj (Release, net10.0). Parse-only or full typecheck." +--- + +# F# Diagnostics + +**Project:** `src/Compiler/FSharp.Compiler.Service.fsproj` | Release | net10.0 | BUILDING_USING_DOTNET=true + +## Setup + +```bash +alias get-fsharp-errors='$(git rev-parse --show-toplevel)/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh' +``` + +## Two-Phase Workflow + +**Step 1 — Parse-only.** Always run first. +```bash +get-fsharp-errors --parse-only src/Compiler/Checking/CheckBasics.fs +``` +If errors → fix syntax. Do NOT proceed to Step 2. + +**Step 2 — Full typecheck.** Only after Step 1 is clean. +```bash +get-fsharp-errors src/Compiler/Checking/CheckBasics.fs +``` + +## Other Commands + +```bash +get-fsharp-errors --check-project # typecheck entire project +get-fsharp-errors --ping # check server is alive +get-fsharp-errors --shutdown # stop server +``` + +## Output + +Clean: `OK` + +Errors (one per line): +``` +ERROR FS0039 (12,5-12,15) The value or constructor 'foo' is not defined | let x = foo +WARNING FS0020 (15,1-15,5) The result is implicitly ignored | doSomething() +``` + +## Notes + +- Server auto-starts on first call, one per repo copy, 4h idle timeout. +- ~3 GB RSS after full project load. First project typecheck ~65s, subsequent file checks <500ms. +- Log: `~/.fsharp-diag/.log` diff --git a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh new file mode 100755 index 00000000000..df72ad24eb4 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +# get-fsharp-errors.sh — minimal passthrough client for fsharp-diag-server +# Usage: +# get-fsharp-errors.sh [--parse-only] +# get-fsharp-errors.sh --check-project +# get-fsharp-errors.sh --ping +# get-fsharp-errors.sh --shutdown + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SERVER_PROJECT="$(cd "$SCRIPT_DIR/../server" && pwd)" +SOCK_DIR="$HOME/.fsharp-diag" + +get_repo_root() { + git rev-parse --show-toplevel 2>/dev/null || pwd +} + +get_socket_path() { + local root="$1" + local hash + hash=$(printf '%s' "$root" | shasum -a 256 | cut -c1-16) + echo "$SOCK_DIR/${hash}.sock" +} + +ensure_server() { + local root="$1" + local sock="$2" + + # Check if socket exists and server responds to ping + if [ -S "$sock" ]; then + local pong + pong=$(printf '{"command":"ping"}\n' | nc -U "$sock" 2>/dev/null || true) + if echo "$pong" | grep -q '"ok"'; then + return 0 + fi + # Stale socket + rm -f "$sock" + fi + + # Start server + mkdir -p "$SOCK_DIR" + local log_hash + log_hash=$(printf '%s' "$root" | shasum -a 256 | cut -c1-16) + local log_file="$SOCK_DIR/${log_hash}.log" + + nohup dotnet run -c Release --project "$SERVER_PROJECT" -- --repo-root "$root" > "$log_file" 2>&1 & + + # Wait for socket to appear (max 60s) + local waited=0 + while [ ! -S "$sock" ] && [ $waited -lt 60 ]; do + sleep 1 + waited=$((waited + 1)) + done + + if [ ! -S "$sock" ]; then + echo '{"error":"Server failed to start within 60s. Check log: '"$log_file"'"}' >&2 + exit 1 + fi +} + +send_request() { + local sock="$1" + local request="$2" + printf '%s\n' "$request" | nc -U "$sock" +} + +# --- Main --- + +REPO_ROOT=$(get_repo_root) +SOCK_PATH=$(get_socket_path "$REPO_ROOT") + +case "${1:-}" in + --ping) + ensure_server "$REPO_ROOT" "$SOCK_PATH" + send_request "$SOCK_PATH" '{"command":"ping"}' + ;; + --shutdown) + send_request "$SOCK_PATH" '{"command":"shutdown"}' + ;; + --parse-only) + shift + FILE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") + ensure_server "$REPO_ROOT" "$SOCK_PATH" + send_request "$SOCK_PATH" "{\"command\":\"parseOnly\",\"file\":\"$FILE\"}" + ;; + --check-project) + ensure_server "$REPO_ROOT" "$SOCK_PATH" + send_request "$SOCK_PATH" '{"command":"checkProject"}' + ;; + -*) + echo "Usage: get-fsharp-errors [--parse-only] " >&2 + echo " get-fsharp-errors --check-project " >&2 + echo " get-fsharp-errors --ping | --shutdown" >&2 + exit 1 + ;; + *) + FILE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") + ensure_server "$REPO_ROOT" "$SOCK_PATH" + send_request "$SOCK_PATH" "{\"command\":\"check\",\"file\":\"$FILE\"}" + ;; +esac diff --git a/.github/skills/fsharp-diagnostics/server/DesignTimeBuild.fs b/.github/skills/fsharp-diagnostics/server/DesignTimeBuild.fs new file mode 100644 index 00000000000..0baf15e1b71 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/server/DesignTimeBuild.fs @@ -0,0 +1,67 @@ +module FSharpDiagServer.DesignTimeBuild + +open System +open System.Diagnostics +open System.IO +open System.Text.Json + +type DtbResult = + { CompilerArgs: string array } + +type DtbConfig = + { TargetFramework: string option + Configuration: string } + +let defaultConfig = + { TargetFramework = Some "net10.0" + Configuration = "Release" } + +let run (fsprojPath: string) (config: DtbConfig) = + async { + let tfmArg = + config.TargetFramework + |> Option.map (fun tfm -> $" /p:TargetFramework={tfm}") + |> Option.defaultValue "" + + let projDir = Path.GetDirectoryName(fsprojPath) + + // /t:Build runs BeforeBuild (generates buildproperties.fs via CompileBefore). + // DesignTimeBuild=true skips dependency projects. + // SkipCompilerExecution=true + ProvideCommandLineArgs=true populates FscCommandLineArgs without compiling. + let psi = + ProcessStartInfo( + FileName = "dotnet", + Arguments = + $"msbuild \"{fsprojPath}\" /t:Build /p:DesignTimeBuild=true /p:SkipCompilerExecution=true /p:ProvideCommandLineArgs=true /p:CopyBuildOutputToOutputDirectory=false /p:CopyOutputSymbolsToOutputDirectory=false /p:BUILDING_USING_DOTNET=true /p:Configuration={config.Configuration}{tfmArg} /nologo /v:q /getItem:FscCommandLineArgs", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WorkingDirectory = projDir + ) + + use proc = Process.Start(psi) + let! stdout = proc.StandardOutput.ReadToEndAsync() |> Async.AwaitTask + let! stderr = proc.StandardError.ReadToEndAsync() |> Async.AwaitTask + do! proc.WaitForExitAsync() |> Async.AwaitTask + + if proc.ExitCode <> 0 then + return Error $"DTB failed (exit {proc.ExitCode}): {stderr}" + else + try + // MSBuild may emit warnings before the JSON; find the JSON start + let jsonStart = stdout.IndexOf('{') + if jsonStart < 0 then + return Error $"No JSON in DTB output: {stdout.[..200]}" + else + let doc = JsonDocument.Parse(stdout.Substring(jsonStart)) + let items = doc.RootElement.GetProperty("Items") + + let args = + items.GetProperty("FscCommandLineArgs").EnumerateArray() + |> Seq.map (fun e -> e.GetProperty("Identity").GetString()) + |> Seq.toArray + + return Ok { CompilerArgs = args } + with ex -> + return Error $"Failed to parse DTB output: {ex.Message}" + } diff --git a/.github/skills/fsharp-diagnostics/server/DiagnosticsFormatter.fs b/.github/skills/fsharp-diagnostics/server/DiagnosticsFormatter.fs new file mode 100644 index 00000000000..e0806fc6682 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/server/DiagnosticsFormatter.fs @@ -0,0 +1,37 @@ +module FSharpDiagServer.DiagnosticsFormatter + +open FSharp.Compiler.Diagnostics + +let private formatOne (getLines: string -> string[]) (d: FSharpDiagnostic) = + let sev = match d.Severity with FSharpDiagnosticSeverity.Error -> "ERROR" | _ -> "WARNING" + let lines = getLines d.Range.FileName + let src = if d.StartLine >= 1 && d.StartLine <= lines.Length then $" | {lines.[d.StartLine - 1].Trim()}" else "" + $"{sev} {d.ErrorNumberText} ({d.StartLine},{d.Start.Column}-{d.EndLine},{d.End.Column}) {d.Message.Replace('\n', ' ').Replace('\r', ' ')}{src}" + +let private withLineReader f = + let cache = System.Collections.Generic.Dictionary() + let getLines path = + match cache.TryGetValue(path) with + | true, l -> l + | _ -> let l = try System.IO.File.ReadAllLines(path) with _ -> [||] in cache.[path] <- l; l + f getLines + +let private relevant (diags: FSharpDiagnostic array) = + diags |> Array.filter (fun d -> d.Severity = FSharpDiagnosticSeverity.Error || d.Severity = FSharpDiagnosticSeverity.Warning) + +let formatFile (diags: FSharpDiagnostic array) = + let diags = relevant diags + if diags.Length = 0 then "OK" + else withLineReader (fun getLines -> diags |> Array.map (formatOne getLines) |> String.concat "\n") + +let formatProject (repoRoot: string) (diags: FSharpDiagnostic array) = + let diags = relevant diags + if diags.Length = 0 then "OK" + else + let root = repoRoot.TrimEnd('/') + "/" + let rel (path: string) = if path.StartsWith(root) then path.Substring(root.Length) else path + withLineReader (fun getLines -> + diags + |> Array.groupBy (fun d -> d.Range.FileName) + |> Array.collect (fun (f, ds) -> Array.append [| $"--- {rel f}" |] (ds |> Array.map (formatOne getLines))) + |> String.concat "\n") diff --git a/.github/skills/fsharp-diagnostics/server/Directory.Build.props b/.github/skills/fsharp-diagnostics/server/Directory.Build.props new file mode 100644 index 00000000000..5a08e96c89f --- /dev/null +++ b/.github/skills/fsharp-diagnostics/server/Directory.Build.props @@ -0,0 +1,9 @@ + + + + false + $(MSBuildThisFileDirectory)../../../../.tools/fsharp-diag/bin/ + $(MSBuildThisFileDirectory)../../../../.tools/fsharp-diag/obj/ + + diff --git a/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj b/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj new file mode 100644 index 00000000000..7f2b01885fa --- /dev/null +++ b/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + + + + + + + + + + + + + + + + diff --git a/.github/skills/fsharp-diagnostics/server/Program.fs b/.github/skills/fsharp-diagnostics/server/Program.fs new file mode 100644 index 00000000000..44e68c070d7 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/server/Program.fs @@ -0,0 +1,31 @@ +module FSharpDiagServer.Program + +open System + +[] +let main argv = + let mutable repoRoot = Environment.CurrentDirectory + + let mutable i = 0 + + while i < argv.Length do + match argv.[i] with + | "--repo-root" when i + 1 < argv.Length -> + repoRoot <- argv.[i + 1] + i <- i + 2 + | other -> + eprintfn $"Unknown argument: {other}" + i <- i + 1 + + // Resolve to absolute path + repoRoot <- IO.Path.GetFullPath(repoRoot) + + let config: Server.ServerConfig = + { RepoRoot = repoRoot + IdleTimeoutMinutes = 240.0 } + + eprintfn $"[fsharp-diag] Starting server for {repoRoot}" + eprintfn $"[fsharp-diag] Socket: {Server.deriveSocketPath repoRoot}" + + Server.startServer config |> Async.RunSynchronously + 0 diff --git a/.github/skills/fsharp-diagnostics/server/ProjectManager.fs b/.github/skills/fsharp-diagnostics/server/ProjectManager.fs new file mode 100644 index 00000000000..f1a955716bf --- /dev/null +++ b/.github/skills/fsharp-diagnostics/server/ProjectManager.fs @@ -0,0 +1,50 @@ +module FSharpDiagServer.ProjectManager + +open System.IO +open FSharp.Compiler.CodeAnalysis + +type ProjectManager(checker: FSharpChecker) = + let mutable cached: (System.DateTime * FSharpProjectOptions) option = None + let gate = obj () + + let isSourceFile (s: string) = + not (s.StartsWith("-")) + && (s.EndsWith(".fs", System.StringComparison.OrdinalIgnoreCase) + || s.EndsWith(".fsi", System.StringComparison.OrdinalIgnoreCase)) + + member _.ResolveProjectOptions(fsprojPath: string) = + async { + let fsprojMtime = File.GetLastWriteTimeUtc(fsprojPath) + let current = + lock gate (fun () -> + match cached with + | Some(mtime, opts) when mtime = fsprojMtime -> Some opts + | Some _ -> cached <- None; None + | None -> None) + + match current with + | Some opts -> return Ok opts + | None -> + let! dtbResult = DesignTimeBuild.run fsprojPath DesignTimeBuild.defaultConfig + + match dtbResult with + | Error msg -> return Error msg + | Ok dtb -> + let projDir = Path.GetDirectoryName(fsprojPath) + + let resolve (s: string) = + if Path.IsPathRooted(s) then s else Path.GetFullPath(Path.Combine(projDir, s)) + + let resolvedArgs = + dtb.CompilerArgs + |> Array.map (fun a -> if isSourceFile a then resolve a else a) + + let sourceFiles = resolvedArgs |> Array.filter isSourceFile + let flagsOnly = resolvedArgs |> Array.filter (not << isSourceFile) + let opts = checker.GetProjectOptionsFromCommandLineArgs(fsprojPath, flagsOnly) + let options = { opts with SourceFiles = sourceFiles } + lock gate (fun () -> cached <- Some(fsprojMtime, options)) + return Ok options + } + + member _.Invalidate() = lock gate (fun () -> cached <- None) diff --git a/.github/skills/fsharp-diagnostics/server/Server.fs b/.github/skills/fsharp-diagnostics/server/Server.fs new file mode 100644 index 00000000000..4770a6daa99 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/server/Server.fs @@ -0,0 +1,147 @@ +module FSharpDiagServer.Server + +open System +open System.IO +open System.Net.Sockets +open System.Security.Cryptography +open System.Text +open System.Text.Json +open System.Threading +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Text + +let private sockDir = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".fsharp-diag") + +let private deriveHash (repoRoot: string) = + SHA256.HashData(Encoding.UTF8.GetBytes(repoRoot)) + |> Convert.ToHexString + |> fun s -> s.Substring(0, 16).ToLowerInvariant() + +let deriveSocketPath repoRoot = Path.Combine(sockDir, $"{deriveHash repoRoot}.sock") +let deriveMetaPath repoRoot = Path.Combine(sockDir, $"{deriveHash repoRoot}.meta.json") +let deriveLogPath repoRoot = Path.Combine(sockDir, $"{deriveHash repoRoot}.log") + +type ServerConfig = { RepoRoot: string; IdleTimeoutMinutes: float } + +let startServer (config: ServerConfig) = + async { + let socketPath = deriveSocketPath config.RepoRoot + let metaPath = deriveMetaPath config.RepoRoot + let fsproj = Path.Combine(config.RepoRoot, "src/Compiler/FSharp.Compiler.Service.fsproj") + Directory.CreateDirectory(sockDir) |> ignore + if File.Exists(socketPath) then File.Delete(socketPath) + + let checker = FSharpChecker.Create(projectCacheSize = 3, useTransparentCompiler = true) + let projectMgr = ProjectManager.ProjectManager(checker) + let mutable lastActivity = DateTimeOffset.UtcNow + let cts = new CancellationTokenSource() + + let getOptions () = projectMgr.ResolveProjectOptions(fsproj) + + let handleRequest (json: string) = + async { + lastActivity <- DateTimeOffset.UtcNow + try + let doc = JsonDocument.Parse(json) + let command = doc.RootElement.GetProperty("command").GetString() + + match command with + | "ping" -> + return $"""{{ "status":"ok", "pid":{Environment.ProcessId} }}""" + + | "parseOnly" -> + let file = doc.RootElement.GetProperty("file").GetString() + if not (File.Exists file) then + return $"""{{ "error":"file not found: {file}" }}""" + else + let sourceText = SourceText.ofString (File.ReadAllText(file)) + // Use project options for correct --langversion, --define etc + let! optionsResult = getOptions () + let parsingArgs = + match optionsResult with + | Ok o -> o.OtherOptions |> Array.toList + | _ -> [] + let parsingOpts, _ = checker.GetParsingOptionsFromCommandLineArgs(file :: parsingArgs) + let! parseResults = checker.ParseFile(file, sourceText, parsingOpts) + return DiagnosticsFormatter.formatFile parseResults.Diagnostics + + | "check" -> + let file = Path.GetFullPath(doc.RootElement.GetProperty("file").GetString()) + if not (File.Exists file) then + return $"""{{ "error":"file not found: {file}" }}""" + else + let! optionsResult = getOptions () + match optionsResult with + | Error msg -> + return $"ERROR: {msg}" + | Ok options -> + let sourceText = SourceText.ofString (File.ReadAllText(file)) + let version = File.GetLastWriteTimeUtc(file).Ticks |> int + let! parseResults, checkAnswer = checker.ParseAndCheckFileInProject(file, version, sourceText, options) + let diags = + match checkAnswer with + | FSharpCheckFileAnswer.Succeeded r -> Array.append parseResults.Diagnostics r.Diagnostics + | FSharpCheckFileAnswer.Aborted -> parseResults.Diagnostics + |> Array.distinctBy (fun d -> d.StartLine, d.Start.Column, d.ErrorNumberText) + return DiagnosticsFormatter.formatFile diags + + | "checkProject" -> + let! optionsResult = getOptions () + match optionsResult with + | Error msg -> + return $"ERROR: {msg}" + | Ok options -> + let! results = checker.ParseAndCheckProject(options) + return DiagnosticsFormatter.formatProject config.RepoRoot results.Diagnostics + + | "shutdown" -> + cts.Cancel() + return """{ "status":"shutting_down" }""" + + | other -> return $"ERROR: unknown command: {other}" + with ex -> + return $"ERROR: {ex.Message}" + } + + File.WriteAllText(metaPath, $"""{{ "repoRoot":"{config.RepoRoot}", "pid":{Environment.ProcessId} }}""") + + use listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified) + listener.Bind(UnixDomainSocketEndPoint(socketPath)) + listener.Listen(10) + File.SetUnixFileMode(socketPath, UnixFileMode.UserRead ||| UnixFileMode.UserWrite ||| UnixFileMode.UserExecute) + eprintfn $"[fsharp-diag] Listening on {socketPath} (pid {Environment.ProcessId})" + + // Idle timeout + Async.Start( + async { + while not cts.Token.IsCancellationRequested do + do! Async.Sleep(60_000 * 60) + if (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > config.IdleTimeoutMinutes then + eprintfn "[fsharp-diag] Idle timeout"; cts.Cancel() + }, cts.Token) + + try + while not cts.Token.IsCancellationRequested do + let! client = listener.AcceptAsync(cts.Token).AsTask() |> Async.AwaitTask + Async.Start( + async { + try + use client = client + use stream = new NetworkStream(client) + use reader = new StreamReader(stream) + use writer = new StreamWriter(stream, AutoFlush = true) + let! line = reader.ReadLineAsync() |> Async.AwaitTask + if line <> null && line.Length > 0 then + let! response = handleRequest line + do! writer.WriteLineAsync(response) |> Async.AwaitTask + with ex -> eprintfn $"[fsharp-diag] Client error: {ex.Message}" + }, cts.Token) + with + | :? OperationCanceledException -> () + | ex -> eprintfn $"[fsharp-diag] Error: {ex.Message}" + + try File.Delete(socketPath) with _ -> () + try File.Delete(metaPath) with _ -> () + eprintfn "[fsharp-diag] Shut down." + } From 47d978ff6e7c91b5be19df1f55333da30ea772e5 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 9 Feb 2026 14:07:40 +0100 Subject: [PATCH 02/21] find all, typehints skills --- .github/skills/fsharp-diagnostics/SKILL.md | 49 ++++---- .../scripts/get-fsharp-errors.sh | 16 +++ .../fsharp-diagnostics/server/Server.fs | 111 ++++++++++++++++++ 3 files changed, 150 insertions(+), 26 deletions(-) diff --git a/.github/skills/fsharp-diagnostics/SKILL.md b/.github/skills/fsharp-diagnostics/SKILL.md index 982d52da693..76b1b2808c2 100644 --- a/.github/skills/fsharp-diagnostics/SKILL.md +++ b/.github/skills/fsharp-diagnostics/SKILL.md @@ -1,51 +1,48 @@ --- name: fsharp-diagnostics -description: "Get F# compiler errors/warnings for src/Compiler/FSharp.Compiler.Service.fsproj (Release, net10.0). Parse-only or full typecheck." +description: "After modifying any F# file, use this to get quick parse errors and typecheck warnings+errors. Also finds symbol references and inferred type hints." --- # F# Diagnostics -**Project:** `src/Compiler/FSharp.Compiler.Service.fsproj` | Release | net10.0 | BUILDING_USING_DOTNET=true +**Scope:** `src/Compiler/` files only (`FSharp.Compiler.Service.fsproj`, Release, net10.0). -## Setup +## Setup (run once per shell session) ```bash -alias get-fsharp-errors='$(git rev-parse --show-toplevel)/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh' +GetErrors() { "$(git rev-parse --show-toplevel)/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh" "$@"; } ``` -## Two-Phase Workflow +## Parse first, typecheck second -**Step 1 — Parse-only.** Always run first. ```bash -get-fsharp-errors --parse-only src/Compiler/Checking/CheckBasics.fs +GetErrors --parse-only src/Compiler/Checking/CheckBasics.fs ``` -If errors → fix syntax. Do NOT proceed to Step 2. - -**Step 2 — Full typecheck.** Only after Step 1 is clean. +If errors → fix syntax. Do NOT typecheck until parse is clean. ```bash -get-fsharp-errors src/Compiler/Checking/CheckBasics.fs +GetErrors src/Compiler/Checking/CheckBasics.fs ``` -## Other Commands +## Find references for a single symbol (line 1-based, col 0-based) +Before renaming or to understand call sites: ```bash -get-fsharp-errors --check-project # typecheck entire project -get-fsharp-errors --ping # check server is alive -get-fsharp-errors --shutdown # stop server +GetErrors --find-refs src/Compiler/Checking/CheckBasics.fs 30 5 ``` -## Output - -Clean: `OK` +## Type hints for a range selection (begin and end line numbers, 1-based) -Errors (one per line): -``` -ERROR FS0039 (12,5-12,15) The value or constructor 'foo' is not defined | let x = foo -WARNING FS0020 (15,1-15,5) The result is implicitly ignored | doSomething() +To see inferred types as inline `// (name: Type)` comments: +```bash +GetErrors --type-hints src/Compiler/TypedTree/TypedTreeOps.fs 1028 1032 ``` -## Notes +## Other + +```bash +GetErrors --check-project # typecheck entire project +GetErrors --ping +GetErrors --shutdown +``` -- Server auto-starts on first call, one per repo copy, 4h idle timeout. -- ~3 GB RSS after full project load. First project typecheck ~65s, subsequent file checks <500ms. -- Log: `~/.fsharp-diag/.log` +First call starts server (~70s cold start, set initial_wait=600). Auto-shuts down after 4h idle. ~3 GB RAM. diff --git a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh index df72ad24eb4..824c37f7628 100755 --- a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh +++ b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh @@ -88,6 +88,22 @@ case "${1:-}" in ensure_server "$REPO_ROOT" "$SOCK_PATH" send_request "$SOCK_PATH" '{"command":"checkProject"}' ;; + --find-refs) + shift + FILE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") + LINE="$2" + COL="$3" + ensure_server "$REPO_ROOT" "$SOCK_PATH" + send_request "$SOCK_PATH" "{\"command\":\"findRefs\",\"file\":\"$FILE\",\"line\":$LINE,\"col\":$COL}" + ;; + --type-hints) + shift + FILE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") + START_LINE="$2" + END_LINE="$3" + ensure_server "$REPO_ROOT" "$SOCK_PATH" + send_request "$SOCK_PATH" "{\"command\":\"typeHints\",\"file\":\"$FILE\",\"startLine\":$START_LINE,\"endLine\":$END_LINE}" + ;; -*) echo "Usage: get-fsharp-errors [--parse-only] " >&2 echo " get-fsharp-errors --check-project " >&2 diff --git a/.github/skills/fsharp-diagnostics/server/Server.fs b/.github/skills/fsharp-diagnostics/server/Server.fs index 4770a6daa99..9b800d0b359 100644 --- a/.github/skills/fsharp-diagnostics/server/Server.fs +++ b/.github/skills/fsharp-diagnostics/server/Server.fs @@ -8,6 +8,7 @@ open System.Text open System.Text.Json open System.Threading open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Symbols open FSharp.Compiler.Text let private sockDir = @@ -95,6 +96,116 @@ let startServer (config: ServerConfig) = let! results = checker.ParseAndCheckProject(options) return DiagnosticsFormatter.formatProject config.RepoRoot results.Diagnostics + | "findRefs" -> + let file = Path.GetFullPath(doc.RootElement.GetProperty("file").GetString()) + let line = doc.RootElement.GetProperty("line").GetInt32() + let col = doc.RootElement.GetProperty("col").GetInt32() + if not (File.Exists file) then + return $"ERROR: file not found: {file}" + else + let! optionsResult = getOptions () + match optionsResult with + | Error msg -> return $"ERROR: {msg}" + | Ok options -> + let sourceText = SourceText.ofString (File.ReadAllText(file)) + let version = File.GetLastWriteTimeUtc(file).Ticks |> int + let! _, checkAnswer = checker.ParseAndCheckFileInProject(file, version, sourceText, options) + match checkAnswer with + | FSharpCheckFileAnswer.Aborted -> return "ERROR: check aborted" + | FSharpCheckFileAnswer.Succeeded checkResults -> + let sourceLines = File.ReadAllLines file + let lineText = sourceLines.[line - 1] + let isIdChar c = Char.IsLetterOrDigit(c) || c = '_' || c = '\'' + let mutable endCol = col + while endCol < lineText.Length && isIdChar lineText.[endCol] do endCol <- endCol + 1 + let mutable startCol = col + while startCol > 0 && isIdChar lineText.[startCol - 1] do startCol <- startCol - 1 + let name = lineText.[startCol..endCol - 1] + if name.Length = 0 then + return "ERROR: no identifier at that position" + else + match checkResults.GetSymbolUseAtLocation(line, endCol, lineText, [name]) with + | None -> return $"ERROR: no symbol found for '{name}' at {line}:{col}" + | Some symbolUse -> + let! projectResults = checker.ParseAndCheckProject(options) + // Collect related symbols: for DU types, also search union cases + let targetNames = ResizeArray() + targetNames.Add(symbolUse.Symbol.FullName) + match symbolUse.Symbol with + | :? FSharpEntity as ent when ent.IsFSharpUnion -> + for uc in ent.UnionCases do targetNames.Add(uc.FullName) + | _ -> () + let uses = + projectResults.GetAllUsesOfAllSymbols() + |> Array.filter (fun u -> targetNames.Contains(u.Symbol.FullName)) + let root = config.RepoRoot.TrimEnd('/') + "/" + let rel (p: string) = if p.StartsWith(root) then p.Substring(root.Length) else p + let lines = + uses |> Array.map (fun u -> + let kind = if u.IsFromDefinition then "DEF" elif u.IsFromType then "TYPE" else "USE" + $"{kind} {rel u.Range.FileName}:{u.Range.StartLine},{u.Range.StartColumn}") + |> Array.distinct + let sym = symbolUse.Symbol + let header = $"Symbol: {sym.DisplayName} ({sym.GetType().Name}) — {lines.Length} references" + return header + "\n" + (lines |> String.concat "\n") + + | "typeHints" -> + let file = Path.GetFullPath(doc.RootElement.GetProperty("file").GetString()) + let startLine = doc.RootElement.GetProperty("startLine").GetInt32() + let endLine = doc.RootElement.GetProperty("endLine").GetInt32() + if not (File.Exists file) then + return $"ERROR: file not found: {file}" + else + let! optionsResult = getOptions () + match optionsResult with + | Error msg -> return $"ERROR: {msg}" + | Ok options -> + let sourceText = SourceText.ofString (File.ReadAllText(file)) + let version = File.GetLastWriteTimeUtc(file).Ticks |> int + let! _, checkAnswer = checker.ParseAndCheckFileInProject(file, version, sourceText, options) + match checkAnswer with + | FSharpCheckFileAnswer.Aborted -> return "ERROR: check aborted" + | FSharpCheckFileAnswer.Succeeded checkResults -> + let allSymbols = checkResults.GetAllUsesOfAllSymbolsInFile() + let sourceLines = File.ReadAllLines(file) + // Collect type annotations per line: (name: Type) + let annotations = System.Collections.Generic.Dictionary>() + let addHint line hint = + if not (annotations.ContainsKey line) then annotations.[line] <- ResizeArray() + annotations.[line].Add(hint) + let tagsToStr (tags: FSharp.Compiler.Text.TaggedText[]) = + tags |> Array.map (fun t -> t.Text) |> String.concat "" + for su in allSymbols do + let r = su.Range + if r.StartLine >= startLine && r.StartLine <= endLine && su.IsFromDefinition then + match su.Symbol with + | :? FSharpMemberOrFunctionOrValue as mfv -> + match mfv.GetReturnTypeLayout(su.DisplayContext) with + | Some tags -> + let typeStr = tagsToStr tags + // Format as F# type annotation: (name: Type) + addHint r.StartLine $"({mfv.DisplayName}: {typeStr})" + | None -> + // Fallback: try FullType + try addHint r.StartLine $"({mfv.DisplayName}: {mfv.FullType.Format(su.DisplayContext)})" + with _ -> () + | :? FSharpField as fld -> + try addHint r.StartLine $"({fld.DisplayName}: {fld.FieldType.Format(su.DisplayContext)})" + with _ -> () + | _ -> () + // Render lines with inline type comments + let sb = StringBuilder() + for i in startLine .. endLine do + if i >= 1 && i <= sourceLines.Length then + let line = sourceLines.[i - 1] + match annotations.TryGetValue(i) with + | true, hints -> + let comment = hints |> Seq.distinct |> String.concat " " + sb.AppendLine($"{line} // {comment}") |> ignore + | _ -> + sb.AppendLine(line) |> ignore + return sb.ToString().TrimEnd() + | "shutdown" -> cts.Cancel() return """{ "status":"shutting_down" }""" From 54641df2d32ef5b468aea19caef822ccbfc73cfd Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 9 Feb 2026 19:14:49 +0100 Subject: [PATCH 03/21] Add CompileFromCheckedProject API to FSharpChecker Add internal CompilationData member on FSharpCheckProjectResults to expose cached typecheck data (TcConfig, TcGlobals, TcImports, CcuThunk, TopAttribs, ILAssemblyRef, CheckedImplFiles). Add public CompileFromCheckedProject method on FSharpChecker that takes check results and an output path, then runs the backend pipeline (sigdata encoding, IL generation, module creation, binary writing) without re-parsing or re-typechecking. Skips optimization and PDB generation for fast dev-loop use. --- src/Compiler/Service/FSharpCheckerResults.fs | 7 + src/Compiler/Service/FSharpCheckerResults.fsi | 6 + src/Compiler/Service/service.fs | 123 ++++++++++++++++++ src/Compiler/Service/service.fsi | 6 + 4 files changed, 142 insertions(+) diff --git a/src/Compiler/Service/FSharpCheckerResults.fs b/src/Compiler/Service/FSharpCheckerResults.fs index b215075fb0e..5bb356d25d9 100644 --- a/src/Compiler/Service/FSharpCheckerResults.fs +++ b/src/Compiler/Service/FSharpCheckerResults.fs @@ -3834,6 +3834,13 @@ type FSharpCheckProjectResults FSharpAssemblyContents(tcGlobals, thisCcu, Some ccuSig, tcImports, mimpls) + member internal _.CompilationData = + let tcGlobals, tcImports, thisCcu, _ccuSig, _, topAttribs, _, ilAssemRef, _, tcAssemblyExpr, _, _ = + getDetails () + + let tcConfig = getTcConfig () + (tcConfig, tcGlobals, tcImports, thisCcu, topAttribs, ilAssemRef, tcAssemblyExpr) + member _.GetOptimizedAssemblyContents() = if not keepAssemblyContents then invalidOp diff --git a/src/Compiler/Service/FSharpCheckerResults.fsi b/src/Compiler/Service/FSharpCheckerResults.fsi index 25c38a49d50..8e7a074ec6a 100644 --- a/src/Compiler/Service/FSharpCheckerResults.fsi +++ b/src/Compiler/Service/FSharpCheckerResults.fsi @@ -532,6 +532,12 @@ type public FSharpCheckProjectResults = /// Get an optimized view of the overall contents of the assembly. Only valid to use if HasCriticalErrors is false. member GetOptimizedAssemblyContents: unit -> FSharpAssemblyContents + /// Get the internal compilation data needed for CompileFromCheckedProject. + /// Requires keepAssemblyContents=true. + member internal CompilationData: + TcConfig * TcGlobals * TcImports * CcuThunk * TopAttribs option * ILAssemblyRef * + CheckedImplFile list option + /// Get the resolution of the ProjectOptions member ProjectContext: FSharpProjectContext diff --git a/src/Compiler/Service/service.fs b/src/Compiler/Service/service.fs index de3635f516f..23c41222ead 100644 --- a/src/Compiler/Service/service.fs +++ b/src/Compiler/Service/service.fs @@ -6,18 +6,27 @@ open System open Internal.Utilities.Collections open Internal.Utilities.Library open FSharp.Compiler +open FSharp.Compiler.AbstractIL.IL open FSharp.Compiler.AbstractIL.ILBinaryReader +open FSharp.Compiler.AbstractIL.ILBinaryWriter +open FSharp.Compiler.CheckExpressionsOps open FSharp.Compiler.CodeAnalysis open FSharp.Compiler.CodeAnalysis.TransparentCompiler open FSharp.Compiler.CompilerConfig +open FSharp.Compiler.CompilerImports open FSharp.Compiler.CompilerOptions +open FSharp.Compiler.CreateILModule open FSharp.Compiler.Diagnostics open FSharp.Compiler.Driver open FSharp.Compiler.DiagnosticsLogger +open FSharp.Compiler.IlxGen +open FSharp.Compiler.OptimizeInputs open FSharp.Compiler.Symbols open FSharp.Compiler.Tokenization open FSharp.Compiler.Text open FSharp.Compiler.Text.Range +open FSharp.Compiler.TypedTree +open FSharp.Compiler.TypedTreeOps /// Callback that indicates whether a requested result has become obsolete. [] @@ -624,6 +633,120 @@ type FSharpChecker member internal _.FrameworkImportsCache = backgroundCompiler.FrameworkImportsCache + /// Compile a DLL from cached typecheck results, skipping parse/typecheck/optimization. + /// For dev-loop use only. Requires keepAssemblyContents=true. + /// Returns the output file path on success. + member _.CompileFromCheckedProject(results: FSharpCheckProjectResults, outfile: string) = + async { + let tcConfig, tcGlobals, tcImports, generatedCcu, topAttrsOpt, _ilAssemRef, typedImplFilesOpt = + results.CompilationData + + let topAttrs = + match topAttrsOpt with + | Some a -> a + | None -> failwith "CompileFromCheckedProject: no top attributes available" + + let typedImplFiles = + match typedImplFilesOpt with + | Some files -> files + | None -> failwith "CompileFromCheckedProject: keepAssemblyContents must be true" + + generatedCcu.Contents.SetAttribs(generatedCcu.Contents.Attribs @ topAttrs.assemblyAttrs) + + let exportRemapping = MakeExportRemapping generatedCcu generatedCcu.Contents + + let sigDataAttributes, sigDataResources = + EncodeSignatureData(tcConfig, tcGlobals, exportRemapping, generatedCcu, outfile, false) + + let optimizedImpls = + typedImplFiles + |> List.map (fun implFile -> + { ImplFile = implFile + OptimizeDuringCodeGen = fun _flag expr -> expr }) + |> CheckedAssemblyAfterOptimization + + let tcVal = LightweightTcValForUsingInBuildMethodCall tcGlobals + + let ilxGenerator = + CreateIlxAssemblyGenerator(tcConfig, tcImports, tcGlobals, tcVal, generatedCcu) + + let codegenResults = + GenerateIlxCode( + IlWriteBackend, + false, + tcConfig, + topAttrs, + optimizedImpls, + generatedCcu.AssemblyName, + ilxGenerator + ) + + let topAssemblyAttrs = codegenResults.topAssemblyAttrs + let topAttrs = { topAttrs with assemblyAttrs = topAssemblyAttrs } + let secDecls = mkILSecurityDecls codegenResults.permissionSets + + let metadataVersion = + match tcConfig.metadataVersion with + | Some v -> v + | _ -> "" + + let ctok = CompilationThreadToken() + + let ilxMainModule = + MainModuleBuilder.CreateMainModule( + ctok, + tcConfig, + tcGlobals, + tcImports, + None, + generatedCcu.AssemblyName, + outfile, + topAttrs, + sigDataAttributes, + sigDataResources, + [], + codegenResults, + None, + metadataVersion, + secDecls + ) + + let normalizeAssemblyRefs (aref: ILAssemblyRef) = + match tcImports.TryFindDllInfo(ctok, rangeStartup, aref.Name, lookupOnly = false) with + | Some dllInfo -> + match dllInfo.ILScopeRef with + | ILScopeRef.Assembly ref -> ref + | _ -> aref + | None -> aref + + WriteILBinaryFile( + { + ilg = tcGlobals.ilg + outfile = outfile + pdbfile = None + emitTailcalls = tcConfig.emitTailcalls + deterministic = tcConfig.deterministic + portablePDB = false + embeddedPDB = false + embedAllSource = false + embedSourceList = [] + allGivenSources = [] + sourceLink = "" + checksumAlgorithm = tcConfig.checksumAlgorithm + signer = None + dumpDebugInfo = false + referenceAssemblyOnly = false + referenceAssemblyAttribOpt = None + referenceAssemblySignatureHash = None + pathMap = tcConfig.pathMap + }, + ilxMainModule, + normalizeAssemblyRefs + ) + + return outfile + } + /// Tokenize a single line, returning token information and a tokenization state represented by an integer member _.TokenizeLine(line: string, state: FSharpTokenizerLexState) = let tokenizer = FSharpSourceTokenizer([], None, None, None) diff --git a/src/Compiler/Service/service.fsi b/src/Compiler/Service/service.fsi index 2120cab1eef..7d78f39dcd0 100644 --- a/src/Compiler/Service/service.fsi +++ b/src/Compiler/Service/service.fsi @@ -508,6 +508,12 @@ type public FSharpChecker = member internal FrameworkImportsCache: FrameworkImportsCache member internal ReferenceResolver: LegacyReferenceResolver + /// Compile a DLL from cached typecheck results, skipping parse/typecheck/optimization. + /// For dev-loop use only. Requires keepAssemblyContents=true. + /// Returns the output file path on success. + member CompileFromCheckedProject: + results: FSharpCheckProjectResults * outfile: string -> Async + /// Tokenize a single line, returning token information and a tokenization state represented by an integer member TokenizeLine: line: string * state: FSharpTokenizerLexState -> FSharpTokenInfo[] * FSharpTokenizerLexState From 267c5e300e0c6640966e6dd825975f3b9a282676 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 9 Feb 2026 20:20:12 +0100 Subject: [PATCH 04/21] Fix quadratic CCU attribute growth in CompileFromCheckedProject Save and restore generatedCcu.Contents.Attribs around the compilation to prevent repeated CompileFromCheckedProject calls from appending assembly attributes to the shared cached CCU on each invocation. Includes regression test and surface area baseline update. --- src/Compiler/Service/service.fs | 9 +++++- ...iler.Service.SurfaceArea.netstandard20.bsl | 1 + .../PerfTests.fs | 30 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/Compiler/Service/service.fs b/src/Compiler/Service/service.fs index 23c41222ead..c86b9a5055e 100644 --- a/src/Compiler/Service/service.fs +++ b/src/Compiler/Service/service.fs @@ -651,7 +651,14 @@ type FSharpChecker | Some files -> files | None -> failwith "CompileFromCheckedProject: keepAssemblyContents must be true" - generatedCcu.Contents.SetAttribs(generatedCcu.Contents.Attribs @ topAttrs.assemblyAttrs) + // Save and restore CCU attribs to prevent quadratic growth on repeated compile calls. + let originalAttribs = generatedCcu.Contents.Attribs + generatedCcu.Contents.SetAttribs(originalAttribs @ topAttrs.assemblyAttrs) + + use _restoreAttribs = + { new System.IDisposable with + member _.Dispose() = + generatedCcu.Contents.SetAttribs(originalAttribs) } let exportRemapping = MakeExportRemapping generatedCcu generatedCcu.Contents diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl index 1954ef2367b..87b5ad3c4f9 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl @@ -2135,6 +2135,7 @@ FSharp.Compiler.CodeAnalysis.FSharpChecker: Microsoft.FSharp.Control.FSharpAsync FSharp.Compiler.CodeAnalysis.FSharpChecker: Microsoft.FSharp.Control.FSharpAsync`1[Microsoft.FSharp.Core.Unit] NotifyProjectCleaned(FSharp.Compiler.CodeAnalysis.FSharpProjectOptions, Microsoft.FSharp.Core.FSharpOption`1[System.String]) FSharp.Compiler.CodeAnalysis.FSharpChecker: Microsoft.FSharp.Control.FSharpAsync`1[System.Collections.Generic.IEnumerable`1[FSharp.Compiler.Text.Range]] FindBackgroundReferencesInFile(System.String, FSharp.Compiler.CodeAnalysis.FSharpProjectOptions, FSharp.Compiler.Symbols.FSharpSymbol, Microsoft.FSharp.Core.FSharpOption`1[System.Boolean], Microsoft.FSharp.Core.FSharpOption`1[System.Boolean], Microsoft.FSharp.Core.FSharpOption`1[System.String]) FSharp.Compiler.CodeAnalysis.FSharpChecker: Microsoft.FSharp.Control.FSharpAsync`1[System.Collections.Generic.IEnumerable`1[FSharp.Compiler.Text.Range]] FindBackgroundReferencesInFile(System.String, FSharpProjectSnapshot, FSharp.Compiler.Symbols.FSharpSymbol, Microsoft.FSharp.Core.FSharpOption`1[System.String]) +FSharp.Compiler.CodeAnalysis.FSharpChecker: Microsoft.FSharp.Control.FSharpAsync`1[System.String] CompileFromCheckedProject(FSharp.Compiler.CodeAnalysis.FSharpCheckProjectResults, System.String) FSharp.Compiler.CodeAnalysis.FSharpChecker: Microsoft.FSharp.Control.FSharpAsync`1[System.Tuple`2[FSharp.Compiler.CodeAnalysis.FSharpParseFileResults,FSharp.Compiler.CodeAnalysis.FSharpCheckFileAnswer]] ParseAndCheckFileInProject(System.String, FSharpProjectSnapshot, Microsoft.FSharp.Core.FSharpOption`1[System.String]) FSharp.Compiler.CodeAnalysis.FSharpChecker: Microsoft.FSharp.Control.FSharpAsync`1[System.Tuple`2[FSharp.Compiler.CodeAnalysis.FSharpParseFileResults,FSharp.Compiler.CodeAnalysis.FSharpCheckFileAnswer]] ParseAndCheckFileInProject(System.String, Int32, FSharp.Compiler.Text.ISourceText, FSharp.Compiler.CodeAnalysis.FSharpProjectOptions, Microsoft.FSharp.Core.FSharpOption`1[System.String]) FSharp.Compiler.CodeAnalysis.FSharpChecker: Microsoft.FSharp.Control.FSharpAsync`1[System.Tuple`2[FSharp.Compiler.CodeAnalysis.FSharpParseFileResults,FSharp.Compiler.CodeAnalysis.FSharpCheckFileResults]] GetBackgroundCheckResultsForFileInProject(System.String, FSharp.Compiler.CodeAnalysis.FSharpProjectOptions, Microsoft.FSharp.Core.FSharpOption`1[System.String]) diff --git a/tests/FSharp.Compiler.Service.Tests/PerfTests.fs b/tests/FSharp.Compiler.Service.Tests/PerfTests.fs index 8a9ac73740a..3a614462f2c 100644 --- a/tests/FSharp.Compiler.Service.Tests/PerfTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/PerfTests.fs @@ -95,3 +95,33 @@ let ``Test request for parse and check doesn't check whole project`` () = printfn "checking no extra background typechecks...., backgroundCheckCount.Value = %d" backgroundCheckCount.Value (backgroundCheckCount.Value <= 10) |> shouldEqual true // only two extra typechecks of files () + +[] +let ``CompileFromCheckedProject does not cause quadratic attribute growth`` () = + let source = """ +module TestLib + +[] +do () + +let add x y = x + y +""" + let options = createProjectOptions [ source ] [] + let compileChecker = FSharpChecker.Create(keepAssemblyContents = true, useTransparentCompiler = false) + let results = compileChecker.ParseAndCheckProject(options) |> Async.RunImmediate + + Assert.False(results.HasCriticalErrors, "Project should have no critical errors") + + let _tcConfig, _tcGlobals, _tcImports, generatedCcu, _topAttrsOpt, _ilAssemRef, _typedImplFilesOpt = + results.CompilationData + + let attribCountBefore = generatedCcu.Contents.Attribs.Length + + let outDir = Path.GetDirectoryName(options.SourceFiles[0]) + for i in 1..3 do + let outFile = Path.Combine(outDir, $"TestLib_{i}.dll") + compileChecker.CompileFromCheckedProject(results, outFile) |> Async.RunImmediate |> ignore + Assert.True(File.Exists(outFile), $"Output DLL should exist: {outFile}") + + let attribCountAfter = generatedCcu.Contents.Attribs.Length + Assert.Equal(attribCountBefore, attribCountAfter) From 91ef2a84fe7bbe9f4d09fdbeb65e2faf92afd64f Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 9 Feb 2026 20:20:17 +0100 Subject: [PATCH 05/21] Add compile handler to diagnostics server Add a 'compile' case to the handleRequest match block in Server.fs that: - Extracts project and output paths from JSON - Resolves project options via projectMgr - Runs ParseAndCheckProject and checks HasCriticalErrors - Calls CompileFromCheckedProject on success - Returns 'OK' on success, 'ERROR: ...' on failure --- .../fsharp-diagnostics/server/Server.fs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/skills/fsharp-diagnostics/server/Server.fs b/.github/skills/fsharp-diagnostics/server/Server.fs index 9b800d0b359..658185bc56b 100644 --- a/.github/skills/fsharp-diagnostics/server/Server.fs +++ b/.github/skills/fsharp-diagnostics/server/Server.fs @@ -206,6 +206,25 @@ let startServer (config: ServerConfig) = sb.AppendLine(line) |> ignore return sb.ToString().TrimEnd() + | "compile" -> + let project = doc.RootElement.GetProperty("project").GetString() + let output = doc.RootElement.GetProperty("output").GetString() + let! optionsResult = projectMgr.ResolveProjectOptions(project) + match optionsResult with + | Error msg -> + return $"ERROR: {msg}" + | Ok options -> + let! results = checker.ParseAndCheckProject(options) + if results.HasCriticalErrors then + let diags = DiagnosticsFormatter.formatProject config.RepoRoot results.Diagnostics + return $"ERROR: Project has errors:\n{diags}" + else + try + let! outPath = checker.CompileFromCheckedProject(results, output) + return "OK" + with ex -> + return $"ERROR: Compile failed: {ex.Message}" + | "shutdown" -> cts.Cancel() return """{ "status":"shutting_down" }""" From cb49fedc01df2e77d100673d86fdde79183d7abd Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 9 Feb 2026 20:38:42 +0100 Subject: [PATCH 06/21] Switch FSharpDiagServer to Proto FCS reference with NuGet fallback Use Proto-built FSharp.Compiler.Service.dll (from artifacts/Bootstrap/fsc/) when available, falling back to NuGet PackageReference otherwise. This allows the diagnostics server to access CompileFromCheckedProject API from current source before it ships in a release. --- .../server/FSharpDiagServer.fsproj | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj b/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj index 7f2b01885fa..7eea4ad37d5 100644 --- a/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj +++ b/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj @@ -3,10 +3,27 @@ Exe net10.0 + + <_DiagServerRepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)', '..', '..', '..', '..')) + <_ProtoFcsPath>$(_DiagServerRepoRoot)artifacts/Bootstrap/fsc/FSharp.Compiler.Service.dll - + + + + $(_ProtoFcsPath) + + + + + + + + From 72e88bfd823b6dbe2e81693f0b54045cba9f3ee8 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 9 Feb 2026 20:45:22 +0100 Subject: [PATCH 07/21] Sprint 3: Multi-project ProjectManager with dictionary cache - ProjectManager: Replace single option cache with Dictionary - ProjectManager: Add path normalization via Path.GetFullPath - ProjectManager: Invalidate accepts optional fsproj path (one or all) - Server: Add resolveProject helper mapping source files to fsproj paths - Server: getOptions now accepts file path parameter - Server: All handlers (parseOnly, check, findRefs, typeHints) pass file to getOptions - Server: checkProject accepts optional 'project' JSON field, defaults to FCS fsproj - Server: Remove hardcoded fsproj variable --- .../server/ProjectManager.fs | 28 ++++++++++++------- .../fsharp-diagnostics/server/Server.fs | 24 +++++++++++----- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/.github/skills/fsharp-diagnostics/server/ProjectManager.fs b/.github/skills/fsharp-diagnostics/server/ProjectManager.fs index f1a955716bf..ee145bd705f 100644 --- a/.github/skills/fsharp-diagnostics/server/ProjectManager.fs +++ b/.github/skills/fsharp-diagnostics/server/ProjectManager.fs @@ -1,10 +1,11 @@ module FSharpDiagServer.ProjectManager +open System.Collections.Generic open System.IO open FSharp.Compiler.CodeAnalysis type ProjectManager(checker: FSharpChecker) = - let mutable cached: (System.DateTime * FSharpProjectOptions) option = None + let cache = Dictionary() let gate = obj () let isSourceFile (s: string) = @@ -12,15 +13,18 @@ type ProjectManager(checker: FSharpChecker) = && (s.EndsWith(".fs", System.StringComparison.OrdinalIgnoreCase) || s.EndsWith(".fsi", System.StringComparison.OrdinalIgnoreCase)) + let normalize (path: string) = Path.GetFullPath(path) + member _.ResolveProjectOptions(fsprojPath: string) = async { - let fsprojMtime = File.GetLastWriteTimeUtc(fsprojPath) + let key = normalize fsprojPath + let fsprojMtime = File.GetLastWriteTimeUtc(key) let current = lock gate (fun () -> - match cached with - | Some(mtime, opts) when mtime = fsprojMtime -> Some opts - | Some _ -> cached <- None; None - | None -> None) + match cache.TryGetValue(key) with + | true, (mtime, opts) when mtime = fsprojMtime -> Some opts + | true, _ -> cache.Remove(key) |> ignore; None + | false, _ -> None) match current with | Some opts -> return Ok opts @@ -30,7 +34,7 @@ type ProjectManager(checker: FSharpChecker) = match dtbResult with | Error msg -> return Error msg | Ok dtb -> - let projDir = Path.GetDirectoryName(fsprojPath) + let projDir = Path.GetDirectoryName(key) let resolve (s: string) = if Path.IsPathRooted(s) then s else Path.GetFullPath(Path.Combine(projDir, s)) @@ -41,10 +45,14 @@ type ProjectManager(checker: FSharpChecker) = let sourceFiles = resolvedArgs |> Array.filter isSourceFile let flagsOnly = resolvedArgs |> Array.filter (not << isSourceFile) - let opts = checker.GetProjectOptionsFromCommandLineArgs(fsprojPath, flagsOnly) + let opts = checker.GetProjectOptionsFromCommandLineArgs(key, flagsOnly) let options = { opts with SourceFiles = sourceFiles } - lock gate (fun () -> cached <- Some(fsprojMtime, options)) + lock gate (fun () -> cache.[key] <- (fsprojMtime, options)) return Ok options } - member _.Invalidate() = lock gate (fun () -> cached <- None) + member _.Invalidate(?fsprojPath: string) = + lock gate (fun () -> + match fsprojPath with + | Some p -> cache.Remove(normalize p) |> ignore + | None -> cache.Clear()) diff --git a/.github/skills/fsharp-diagnostics/server/Server.fs b/.github/skills/fsharp-diagnostics/server/Server.fs index 658185bc56b..532c9f16e76 100644 --- a/.github/skills/fsharp-diagnostics/server/Server.fs +++ b/.github/skills/fsharp-diagnostics/server/Server.fs @@ -29,7 +29,6 @@ let startServer (config: ServerConfig) = async { let socketPath = deriveSocketPath config.RepoRoot let metaPath = deriveMetaPath config.RepoRoot - let fsproj = Path.Combine(config.RepoRoot, "src/Compiler/FSharp.Compiler.Service.fsproj") Directory.CreateDirectory(sockDir) |> ignore if File.Exists(socketPath) then File.Delete(socketPath) @@ -38,7 +37,14 @@ let startServer (config: ServerConfig) = let mutable lastActivity = DateTimeOffset.UtcNow let cts = new CancellationTokenSource() - let getOptions () = projectMgr.ResolveProjectOptions(fsproj) + let resolveProject (filePath: string) = + let rel = filePath.Replace(config.RepoRoot.TrimEnd('/') + "/", "") + if rel.StartsWith("tests/FSharp.Compiler.ComponentTests/") then + Path.Combine(config.RepoRoot, "tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj") + else + Path.Combine(config.RepoRoot, "src/Compiler/FSharp.Compiler.Service.fsproj") + + let getOptions (filePath: string) = projectMgr.ResolveProjectOptions(resolveProject filePath) let handleRequest (json: string) = async { @@ -58,7 +64,7 @@ let startServer (config: ServerConfig) = else let sourceText = SourceText.ofString (File.ReadAllText(file)) // Use project options for correct --langversion, --define etc - let! optionsResult = getOptions () + let! optionsResult = getOptions file let parsingArgs = match optionsResult with | Ok o -> o.OtherOptions |> Array.toList @@ -72,7 +78,7 @@ let startServer (config: ServerConfig) = if not (File.Exists file) then return $"""{{ "error":"file not found: {file}" }}""" else - let! optionsResult = getOptions () + let! optionsResult = getOptions file match optionsResult with | Error msg -> return $"ERROR: {msg}" @@ -88,7 +94,11 @@ let startServer (config: ServerConfig) = return DiagnosticsFormatter.formatFile diags | "checkProject" -> - let! optionsResult = getOptions () + let project = + match doc.RootElement.TryGetProperty("project") with + | true, p -> p.GetString() + | false, _ -> Path.Combine(config.RepoRoot, "src/Compiler/FSharp.Compiler.Service.fsproj") + let! optionsResult = projectMgr.ResolveProjectOptions(project) match optionsResult with | Error msg -> return $"ERROR: {msg}" @@ -103,7 +113,7 @@ let startServer (config: ServerConfig) = if not (File.Exists file) then return $"ERROR: file not found: {file}" else - let! optionsResult = getOptions () + let! optionsResult = getOptions file match optionsResult with | Error msg -> return $"ERROR: {msg}" | Ok options -> @@ -156,7 +166,7 @@ let startServer (config: ServerConfig) = if not (File.Exists file) then return $"ERROR: file not found: {file}" else - let! optionsResult = getOptions () + let! optionsResult = getOptions file match optionsResult with | Error msg -> return $"ERROR: {msg}" | Ok options -> From 2675dcad831ac7267286177fa20607d525275d4d Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 9 Feb 2026 20:57:35 +0100 Subject: [PATCH 08/21] Sprint 03: Multi-project ProjectManager with tests - ProjectManager.fs: Dictionary cache with path normalization and optional Invalidate(?fsprojPath) - ProjectRouting.fs: extracted resolveProject for testability, maps source files to fsproj (ComponentTests or FCS) - Server.fs: uses ProjectRouting.resolveProject, all handlers route via file path - checkProject accepts optional 'project' JSON field, defaults to FCS fsproj - Tests: 10 tests covering resolveProject mapping and ProjectManager.Invalidate --- .../server/FSharpDiagServer.fsproj | 1 + .../server/ProjectRouting.fs | 11 ++++ .../fsharp-diagnostics/server/Server.fs | 9 +--- .../tests/Directory.Build.props | 7 +++ .../tests/FSharpDiagServer.Tests.fsproj | 24 +++++++++ .../tests/ProjectManagerTests.fs | 34 ++++++++++++ .../tests/ResolveProjectTests.fs | 52 +++++++++++++++++++ 7 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 .github/skills/fsharp-diagnostics/server/ProjectRouting.fs create mode 100644 .github/skills/fsharp-diagnostics/tests/Directory.Build.props create mode 100644 .github/skills/fsharp-diagnostics/tests/FSharpDiagServer.Tests.fsproj create mode 100644 .github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs create mode 100644 .github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs diff --git a/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj b/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj index 7eea4ad37d5..61c33e785ef 100644 --- a/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj +++ b/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj @@ -30,6 +30,7 @@ + diff --git a/.github/skills/fsharp-diagnostics/server/ProjectRouting.fs b/.github/skills/fsharp-diagnostics/server/ProjectRouting.fs new file mode 100644 index 00000000000..9ff8c970a25 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/server/ProjectRouting.fs @@ -0,0 +1,11 @@ +module FSharpDiagServer.ProjectRouting + +open System.IO + +let resolveProject (repoRoot: string) (filePath: string) = + let rel = filePath.Replace(repoRoot.TrimEnd('/') + "/", "") + + if rel.StartsWith("tests/FSharp.Compiler.ComponentTests/") then + Path.Combine(repoRoot, "tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj") + else + Path.Combine(repoRoot, "src/Compiler/FSharp.Compiler.Service.fsproj") diff --git a/.github/skills/fsharp-diagnostics/server/Server.fs b/.github/skills/fsharp-diagnostics/server/Server.fs index 532c9f16e76..0f4dd4fa4d8 100644 --- a/.github/skills/fsharp-diagnostics/server/Server.fs +++ b/.github/skills/fsharp-diagnostics/server/Server.fs @@ -37,14 +37,7 @@ let startServer (config: ServerConfig) = let mutable lastActivity = DateTimeOffset.UtcNow let cts = new CancellationTokenSource() - let resolveProject (filePath: string) = - let rel = filePath.Replace(config.RepoRoot.TrimEnd('/') + "/", "") - if rel.StartsWith("tests/FSharp.Compiler.ComponentTests/") then - Path.Combine(config.RepoRoot, "tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj") - else - Path.Combine(config.RepoRoot, "src/Compiler/FSharp.Compiler.Service.fsproj") - - let getOptions (filePath: string) = projectMgr.ResolveProjectOptions(resolveProject filePath) + let getOptions (filePath: string) = projectMgr.ResolveProjectOptions(ProjectRouting.resolveProject config.RepoRoot filePath) let handleRequest (json: string) = async { diff --git a/.github/skills/fsharp-diagnostics/tests/Directory.Build.props b/.github/skills/fsharp-diagnostics/tests/Directory.Build.props new file mode 100644 index 00000000000..3e4e8b38c63 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/tests/Directory.Build.props @@ -0,0 +1,7 @@ + + + false + $(MSBuildThisFileDirectory)../../../../.tools/fsharp-diag/test-bin/ + $(MSBuildThisFileDirectory)../../../../.tools/fsharp-diag/test-obj/ + + diff --git a/.github/skills/fsharp-diagnostics/tests/FSharpDiagServer.Tests.fsproj b/.github/skills/fsharp-diagnostics/tests/FSharpDiagServer.Tests.fsproj new file mode 100644 index 00000000000..eab2ab4ba62 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/tests/FSharpDiagServer.Tests.fsproj @@ -0,0 +1,24 @@ + + + + net10.0 + false + + + + + + + + + + + + + + + + + + + diff --git a/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs b/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs new file mode 100644 index 00000000000..78836accbc0 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs @@ -0,0 +1,34 @@ +module FSharpDiagServer.Tests.ProjectManagerTests + +open Xunit +open FSharp.Compiler.CodeAnalysis +open FSharpDiagServer.ProjectManager + +[] +let ``Invalidate without args does not throw`` () = + let checker = FSharpChecker.Create() + let mgr = ProjectManager(checker) + // Should not throw even when cache is empty + mgr.Invalidate() + +[] +let ``Invalidate with specific path does not throw`` () = + let checker = FSharpChecker.Create() + let mgr = ProjectManager(checker) + mgr.Invalidate("/some/nonexistent.fsproj") + +[] +let ``Invalidate all clears after invalidate-specific`` () = + let checker = FSharpChecker.Create() + let mgr = ProjectManager(checker) + mgr.Invalidate("/a.fsproj") + mgr.Invalidate() + +[] +let ``Multiple invalidate calls are idempotent`` () = + let checker = FSharpChecker.Create() + let mgr = ProjectManager(checker) + mgr.Invalidate() + mgr.Invalidate() + mgr.Invalidate("/x.fsproj") + mgr.Invalidate("/x.fsproj") diff --git a/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs b/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs new file mode 100644 index 00000000000..94f5c1251d7 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs @@ -0,0 +1,52 @@ +module FSharpDiagServer.Tests.ResolveProjectTests + +open Xunit +open FSharpDiagServer.ProjectRouting + +[] +let ``resolveProject maps FCS source file to FCS fsproj`` () = + let repoRoot = "/repo" + let file = "/repo/src/Compiler/SyntaxTree/SyntaxTree.fs" + let result = resolveProject repoRoot file + Assert.Equal(System.IO.Path.Combine(repoRoot, "src/Compiler/FSharp.Compiler.Service.fsproj"), result) + +[] +let ``resolveProject maps ComponentTests file to ComponentTests fsproj`` () = + let repoRoot = "/repo" + let file = "/repo/tests/FSharp.Compiler.ComponentTests/SomeTest.fs" + let result = resolveProject repoRoot file + Assert.Equal( + System.IO.Path.Combine(repoRoot, "tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj"), + result) + +[] +let ``resolveProject defaults unknown paths to FCS fsproj`` () = + let repoRoot = "/repo" + let file = "/repo/some/other/path/File.fs" + let result = resolveProject repoRoot file + Assert.Equal(System.IO.Path.Combine(repoRoot, "src/Compiler/FSharp.Compiler.Service.fsproj"), result) + +[] +let ``resolveProject handles trailing slash in repoRoot`` () = + let repoRoot = "/repo/" + let file = "/repo/tests/FSharp.Compiler.ComponentTests/SomeTest.fs" + let result = resolveProject repoRoot file + Assert.Equal( + System.IO.Path.Combine(repoRoot, "tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj"), + result) + +[] +let ``resolveProject handles repoRoot without trailing slash`` () = + let repoRoot = "/repo" + let file = "/repo/src/Compiler/Checking/Foo.fs" + let result = resolveProject repoRoot file + Assert.Equal(System.IO.Path.Combine(repoRoot, "src/Compiler/FSharp.Compiler.Service.fsproj"), result) + +[] +let ``resolveProject with nested ComponentTests subfolder`` () = + let repoRoot = "/repo" + let file = "/repo/tests/FSharp.Compiler.ComponentTests/Language/SubDir/Test.fs" + let result = resolveProject repoRoot file + Assert.Equal( + System.IO.Path.Combine(repoRoot, "tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj"), + result) From 3f7e9b0ce40602d77ae16aaf5b617826cc9f86e8 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 9 Feb 2026 21:05:40 +0100 Subject: [PATCH 09/21] Sprint 03: Multi-project ProjectManager with behavioral tests - Add CacheCount, HasCachedProject, InjectTestEntry to ProjectManager for testability - Rewrite ProjectManagerTests with behavioral assertions (cache population, selective/full invalidation, path normalization) - Refactor ResolveProjectTests to use Theory/InlineData, eliminating copy-paste - Shared createManager/dummyOptions helpers eliminate repeated setup - 13 tests: 7 ProjectManager + 6 routing (all pass) --- .../server/ProjectManager.fs | 9 ++ .../tests/ProjectManagerTests.fs | 82 +++++++++++++++---- .../tests/ResolveProjectTests.fs | 60 ++++---------- 3 files changed, 89 insertions(+), 62 deletions(-) diff --git a/.github/skills/fsharp-diagnostics/server/ProjectManager.fs b/.github/skills/fsharp-diagnostics/server/ProjectManager.fs index ee145bd705f..98e3d4a8a5c 100644 --- a/.github/skills/fsharp-diagnostics/server/ProjectManager.fs +++ b/.github/skills/fsharp-diagnostics/server/ProjectManager.fs @@ -56,3 +56,12 @@ type ProjectManager(checker: FSharpChecker) = match fsprojPath with | Some p -> cache.Remove(normalize p) |> ignore | None -> cache.Clear()) + + member _.CacheCount = lock gate (fun () -> cache.Count) + + member _.HasCachedProject(fsprojPath: string) = + lock gate (fun () -> cache.ContainsKey(normalize fsprojPath)) + + member _.InjectTestEntry(fsprojPath: string, options: FSharpProjectOptions) = + let key = normalize fsprojPath + lock gate (fun () -> cache.[key] <- (System.DateTime.MinValue, options)) diff --git a/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs b/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs index 78836accbc0..27b687346b0 100644 --- a/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs +++ b/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs @@ -4,31 +4,79 @@ open Xunit open FSharp.Compiler.CodeAnalysis open FSharpDiagServer.ProjectManager -[] -let ``Invalidate without args does not throw`` () = +let private createManager () = let checker = FSharpChecker.Create() - let mgr = ProjectManager(checker) - // Should not throw even when cache is empty - mgr.Invalidate() + ProjectManager(checker) + +let private dummyOptions projPath = + { FSharpProjectOptions.ProjectFileName = projPath + ProjectId = None + SourceFiles = [||] + OtherOptions = [||] + ReferencedProjects = [||] + IsIncompleteTypeCheckEnvironment = false + UseScriptResolutionRules = false + LoadTime = System.DateTime.MinValue + UnresolvedReferences = None + OriginalLoadReferences = [] + Stamp = None } [] -let ``Invalidate with specific path does not throw`` () = - let checker = FSharpChecker.Create() - let mgr = ProjectManager(checker) - mgr.Invalidate("/some/nonexistent.fsproj") +let ``New manager has empty cache`` () = + let mgr = createManager () + Assert.Equal(0, mgr.CacheCount) [] -let ``Invalidate all clears after invalidate-specific`` () = - let checker = FSharpChecker.Create() - let mgr = ProjectManager(checker) +let ``InjectTestEntry populates cache`` () = + let mgr = createManager () + mgr.InjectTestEntry("/a.fsproj", dummyOptions "/a.fsproj") + Assert.Equal(1, mgr.CacheCount) + Assert.True(mgr.HasCachedProject("/a.fsproj")) + +[] +let ``Invalidate specific path removes only that project`` () = + let mgr = createManager () + mgr.InjectTestEntry("/a.fsproj", dummyOptions "/a.fsproj") + mgr.InjectTestEntry("/b.fsproj", dummyOptions "/b.fsproj") + Assert.Equal(2, mgr.CacheCount) + mgr.Invalidate("/a.fsproj") - mgr.Invalidate() + + Assert.Equal(1, mgr.CacheCount) + Assert.False(mgr.HasCachedProject("/a.fsproj")) + Assert.True(mgr.HasCachedProject("/b.fsproj")) [] -let ``Multiple invalidate calls are idempotent`` () = - let checker = FSharpChecker.Create() - let mgr = ProjectManager(checker) +let ``Invalidate all clears entire cache`` () = + let mgr = createManager () + mgr.InjectTestEntry("/a.fsproj", dummyOptions "/a.fsproj") + mgr.InjectTestEntry("/b.fsproj", dummyOptions "/b.fsproj") + mgr.Invalidate() + + Assert.Equal(0, mgr.CacheCount) + Assert.False(mgr.HasCachedProject("/a.fsproj")) + Assert.False(mgr.HasCachedProject("/b.fsproj")) + +[] +let ``Invalidate nonexistent path leaves cache unchanged`` () = + let mgr = createManager () + mgr.InjectTestEntry("/a.fsproj", dummyOptions "/a.fsproj") + + mgr.Invalidate("/nonexistent.fsproj") + + Assert.Equal(1, mgr.CacheCount) + Assert.True(mgr.HasCachedProject("/a.fsproj")) + +[] +let ``Invalidate on empty cache is idempotent`` () = + let mgr = createManager () mgr.Invalidate() mgr.Invalidate("/x.fsproj") - mgr.Invalidate("/x.fsproj") + Assert.Equal(0, mgr.CacheCount) + +[] +let ``HasCachedProject normalizes paths`` () = + let mgr = createManager () + mgr.InjectTestEntry("/a/../b/c.fsproj", dummyOptions "/b/c.fsproj") + Assert.True(mgr.HasCachedProject("/b/c.fsproj")) diff --git a/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs b/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs index 94f5c1251d7..4059c588bee 100644 --- a/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs +++ b/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs @@ -3,50 +3,20 @@ module FSharpDiagServer.Tests.ResolveProjectTests open Xunit open FSharpDiagServer.ProjectRouting -[] -let ``resolveProject maps FCS source file to FCS fsproj`` () = - let repoRoot = "/repo" - let file = "/repo/src/Compiler/SyntaxTree/SyntaxTree.fs" - let result = resolveProject repoRoot file - Assert.Equal(System.IO.Path.Combine(repoRoot, "src/Compiler/FSharp.Compiler.Service.fsproj"), result) +let private fcs root = + System.IO.Path.Combine(root, "src/Compiler/FSharp.Compiler.Service.fsproj") -[] -let ``resolveProject maps ComponentTests file to ComponentTests fsproj`` () = - let repoRoot = "/repo" - let file = "/repo/tests/FSharp.Compiler.ComponentTests/SomeTest.fs" - let result = resolveProject repoRoot file - Assert.Equal( - System.IO.Path.Combine(repoRoot, "tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj"), - result) +let private componentTests root = + System.IO.Path.Combine(root, "tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj") -[] -let ``resolveProject defaults unknown paths to FCS fsproj`` () = - let repoRoot = "/repo" - let file = "/repo/some/other/path/File.fs" - let result = resolveProject repoRoot file - Assert.Equal(System.IO.Path.Combine(repoRoot, "src/Compiler/FSharp.Compiler.Service.fsproj"), result) - -[] -let ``resolveProject handles trailing slash in repoRoot`` () = - let repoRoot = "/repo/" - let file = "/repo/tests/FSharp.Compiler.ComponentTests/SomeTest.fs" - let result = resolveProject repoRoot file - Assert.Equal( - System.IO.Path.Combine(repoRoot, "tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj"), - result) - -[] -let ``resolveProject handles repoRoot without trailing slash`` () = - let repoRoot = "/repo" - let file = "/repo/src/Compiler/Checking/Foo.fs" - let result = resolveProject repoRoot file - Assert.Equal(System.IO.Path.Combine(repoRoot, "src/Compiler/FSharp.Compiler.Service.fsproj"), result) - -[] -let ``resolveProject with nested ComponentTests subfolder`` () = - let repoRoot = "/repo" - let file = "/repo/tests/FSharp.Compiler.ComponentTests/Language/SubDir/Test.fs" - let result = resolveProject repoRoot file - Assert.Equal( - System.IO.Path.Combine(repoRoot, "tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj"), - result) +[] +[] +[] +[] +[] +[] +[] +let ``resolveProject routes files to correct fsproj`` (repoRoot: string, filePath: string, expectFcs: bool) = + let result = resolveProject repoRoot filePath + let expected = if expectFcs then fcs repoRoot else componentTests repoRoot + Assert.Equal(expected, result) From 6ba38e66a6aa3573ded38f819bb90895de98403b Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 9 Feb 2026 21:15:40 +0100 Subject: [PATCH 10/21] Sprint 03: Fix test-only members visibility and add missing edge-case tests - Mark CacheCount, HasCachedProject, InjectTestEntry as internal - Add InternalsVisibleTo for test project in server fsproj - Add test: Invalidate normalizes path before removal - Add test: InjectTestEntry overwrites existing entry (idempotency) - Add test: resolveProject with out-of-repo file defaults to FCS --- .../server/FSharpDiagServer.fsproj | 4 ++++ .../server/ProjectManager.fs | 6 +++--- .../tests/ProjectManagerTests.fs | 18 ++++++++++++++++++ .../tests/ResolveProjectTests.fs | 1 + 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj b/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj index 61c33e785ef..37b8f562cf4 100644 --- a/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj +++ b/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj @@ -27,6 +27,10 @@ + + + + diff --git a/.github/skills/fsharp-diagnostics/server/ProjectManager.fs b/.github/skills/fsharp-diagnostics/server/ProjectManager.fs index 98e3d4a8a5c..a292e1848bf 100644 --- a/.github/skills/fsharp-diagnostics/server/ProjectManager.fs +++ b/.github/skills/fsharp-diagnostics/server/ProjectManager.fs @@ -57,11 +57,11 @@ type ProjectManager(checker: FSharpChecker) = | Some p -> cache.Remove(normalize p) |> ignore | None -> cache.Clear()) - member _.CacheCount = lock gate (fun () -> cache.Count) + member internal _.CacheCount = lock gate (fun () -> cache.Count) - member _.HasCachedProject(fsprojPath: string) = + member internal _.HasCachedProject(fsprojPath: string) = lock gate (fun () -> cache.ContainsKey(normalize fsprojPath)) - member _.InjectTestEntry(fsprojPath: string, options: FSharpProjectOptions) = + member internal _.InjectTestEntry(fsprojPath: string, options: FSharpProjectOptions) = let key = normalize fsprojPath lock gate (fun () -> cache.[key] <- (System.DateTime.MinValue, options)) diff --git a/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs b/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs index 27b687346b0..5609cc3829b 100644 --- a/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs +++ b/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs @@ -80,3 +80,21 @@ let ``HasCachedProject normalizes paths`` () = let mgr = createManager () mgr.InjectTestEntry("/a/../b/c.fsproj", dummyOptions "/b/c.fsproj") Assert.True(mgr.HasCachedProject("/b/c.fsproj")) + +[] +let ``Invalidate normalizes path before removal`` () = + let mgr = createManager () + mgr.InjectTestEntry("/b/c.fsproj", dummyOptions "/b/c.fsproj") + Assert.Equal(1, mgr.CacheCount) + + mgr.Invalidate("/a/../b/c.fsproj") + + Assert.Equal(0, mgr.CacheCount) + Assert.False(mgr.HasCachedProject("/b/c.fsproj")) + +[] +let ``InjectTestEntry overwrites existing entry for same normalized path`` () = + let mgr = createManager () + mgr.InjectTestEntry("/a.fsproj", dummyOptions "/a.fsproj") + mgr.InjectTestEntry("/a.fsproj", dummyOptions "/a.fsproj") + Assert.Equal(1, mgr.CacheCount) diff --git a/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs b/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs index 4059c588bee..caee848e804 100644 --- a/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs +++ b/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs @@ -16,6 +16,7 @@ let private componentTests root = [] [] [] +[] let ``resolveProject routes files to correct fsproj`` (repoRoot: string, filePath: string, expectFcs: bool) = let result = resolveProject repoRoot filePath let expected = if expectFcs then fcs repoRoot else componentTests repoRoot From a8a43ceab7645f0a458c6fc93deed3e72e70974e Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 9 Feb 2026 21:24:16 +0100 Subject: [PATCH 11/21] Sprint 4: Extend FastBuildFromCache MSBuild targets to ComponentTests - Update guard condition in FastBuildFromCache.targets to also match AssemblyName=='FSharp.Compiler.ComponentTests' - Add Import of FastBuildFromCache.targets to FSharpTests.Directory.Build.targets (mirrors existing import in FSharpBuild.Directory.Build.targets) - Fix XML comment containing '--compile' (invalid in XML comments) - Update header/guard comments to mention ComponentTests --- FSharpTests.Directory.Build.targets | 4 + eng/targets/FastBuildFromCache.targets | 179 +++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 eng/targets/FastBuildFromCache.targets diff --git a/FSharpTests.Directory.Build.targets b/FSharpTests.Directory.Build.targets index 2e0b335b411..9b8d49a124d 100644 --- a/FSharpTests.Directory.Build.targets +++ b/FSharpTests.Directory.Build.targets @@ -42,4 +42,8 @@ + + + diff --git a/eng/targets/FastBuildFromCache.targets b/eng/targets/FastBuildFromCache.targets new file mode 100644 index 00000000000..5a2eebf0001 --- /dev/null +++ b/eng/targets/FastBuildFromCache.targets @@ -0,0 +1,179 @@ + + + + + + <_FastBuildFromCacheActive>true + <_FastBuildScript>$(RepoRoot).github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh + + + + + + + + + + + + + + + + true + + + + + + + + + + + + + + + + + + + From fa7aa07ddf61ad6ad9f4dfafe110bbeedcc5b9cb Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 9 Feb 2026 21:55:44 +0100 Subject: [PATCH 12/21] Sprint 5 Fixup: Address verifier feedback CODE-QUALITY: - Fix unused outPath binding in Server.fs compile handler (let! _ =) - Improve ProjectRouting.resolveProject to use StartsWith prefix check instead of fragile String.Replace, with StringComparison.Ordinal - Add XML doc comment to resolveProject NO-LEFTOVERS: - Remove stale src/FastBuildFromCache.targets (old approach, superseded by eng/targets/) - Remove leftover plan files (FAST_COMPILE_PLAN.md, REUSABLE_COMPILE_PLAN.md) - Revert incorrect Directory.Build.targets import (wrong path) - Add missing FSharpBuild.Directory.Build.targets import for src/ projects - Include uncommitted shell script --compile handler and SKILL.md docs TEST-COVERAGE: - Add DesignTimeBuildTests for config defaults and DtbResult construction - Add ProjectRouting tests: vsintegration path, FSharp.Core path, trailing slash, repoRoot-as-substring edge case - Add ProjectManager tests: 3-project coexistence, selective invalidation TEST-CODE-QUALITY: - Tests use descriptive names and verify specific behaviors - Edge case tests validate the String.Replace fix All 24 tests pass. Server builds with 0 warnings. --- .github/skills/fsharp-diagnostics/SKILL.md | 8 ++++++ .../scripts/get-fsharp-errors.sh | 7 ++++++ .../server/ProjectRouting.fs | 12 +++++++-- .../fsharp-diagnostics/server/Server.fs | 2 +- .../tests/DesignTimeBuildTests.fs | 15 +++++++++++ .../tests/FSharpDiagServer.Tests.fsproj | 1 + .../tests/ProjectManagerTests.fs | 25 +++++++++++++++++++ .../tests/ResolveProjectTests.fs | 21 ++++++++++++++++ FSharpBuild.Directory.Build.targets | 4 +++ 9 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 .github/skills/fsharp-diagnostics/tests/DesignTimeBuildTests.fs diff --git a/.github/skills/fsharp-diagnostics/SKILL.md b/.github/skills/fsharp-diagnostics/SKILL.md index 76b1b2808c2..33d3034bf2f 100644 --- a/.github/skills/fsharp-diagnostics/SKILL.md +++ b/.github/skills/fsharp-diagnostics/SKILL.md @@ -37,6 +37,14 @@ To see inferred types as inline `// (name: Type)` comments: GetErrors --type-hints src/Compiler/TypedTree/TypedTreeOps.fs 1028 1032 ``` +## Running tests faster (FSharp.Compiler.Service only) + +After checking errors are clean, run tests with cached compilation (skips full recompile of FSharp.Compiler.Service): +```bash +dotnet test tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj -c Release /p:FastBuildFromCache=true +``` +Only affects FSharp.Compiler.Service.fsproj build. All other projects build normally. Falls back to normal build if server is unavailable. + ## Other ```bash diff --git a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh index 824c37f7628..2bd9cf9e5b2 100755 --- a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh +++ b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh @@ -104,6 +104,13 @@ case "${1:-}" in ensure_server "$REPO_ROOT" "$SOCK_PATH" send_request "$SOCK_PATH" "{\"command\":\"typeHints\",\"file\":\"$FILE\",\"startLine\":$START_LINE,\"endLine\":$END_LINE}" ;; + --compile) + shift + PROJECT="$1" + OUTPUT="$2" + ensure_server "$REPO_ROOT" "$SOCK_PATH" + send_request "$SOCK_PATH" "{\"command\":\"compile\",\"project\":\"$PROJECT\",\"output\":\"$OUTPUT\"}" + ;; -*) echo "Usage: get-fsharp-errors [--parse-only] " >&2 echo " get-fsharp-errors --check-project " >&2 diff --git a/.github/skills/fsharp-diagnostics/server/ProjectRouting.fs b/.github/skills/fsharp-diagnostics/server/ProjectRouting.fs index 9ff8c970a25..38a60afd2f6 100644 --- a/.github/skills/fsharp-diagnostics/server/ProjectRouting.fs +++ b/.github/skills/fsharp-diagnostics/server/ProjectRouting.fs @@ -2,10 +2,18 @@ module FSharpDiagServer.ProjectRouting open System.IO +/// Maps a source file path to the fsproj that owns it. +/// Falls back to FSharp.Compiler.Service for any unrecognized path. let resolveProject (repoRoot: string) (filePath: string) = - let rel = filePath.Replace(repoRoot.TrimEnd('/') + "/", "") + let root = repoRoot.TrimEnd('/') + "/" - if rel.StartsWith("tests/FSharp.Compiler.ComponentTests/") then + let rel = + if filePath.StartsWith(root, System.StringComparison.Ordinal) then + filePath.Substring(root.Length) + else + filePath + + if rel.StartsWith("tests/FSharp.Compiler.ComponentTests/", System.StringComparison.Ordinal) then Path.Combine(repoRoot, "tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj") else Path.Combine(repoRoot, "src/Compiler/FSharp.Compiler.Service.fsproj") diff --git a/.github/skills/fsharp-diagnostics/server/Server.fs b/.github/skills/fsharp-diagnostics/server/Server.fs index 0f4dd4fa4d8..f0e9ddc9bb9 100644 --- a/.github/skills/fsharp-diagnostics/server/Server.fs +++ b/.github/skills/fsharp-diagnostics/server/Server.fs @@ -223,7 +223,7 @@ let startServer (config: ServerConfig) = return $"ERROR: Project has errors:\n{diags}" else try - let! outPath = checker.CompileFromCheckedProject(results, output) + let! _ = checker.CompileFromCheckedProject(results, output) return "OK" with ex -> return $"ERROR: Compile failed: {ex.Message}" diff --git a/.github/skills/fsharp-diagnostics/tests/DesignTimeBuildTests.fs b/.github/skills/fsharp-diagnostics/tests/DesignTimeBuildTests.fs new file mode 100644 index 00000000000..7d3a019ee95 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/tests/DesignTimeBuildTests.fs @@ -0,0 +1,15 @@ +module FSharpDiagServer.Tests.DesignTimeBuildTests + +open Xunit +open FSharpDiagServer.DesignTimeBuild + +[] +let ``defaultConfig has expected values`` () = + Assert.Equal(Some "net10.0", defaultConfig.TargetFramework) + Assert.Equal("Release", defaultConfig.Configuration) + +[] +let ``DtbResult can hold compiler args`` () = + let result = { CompilerArgs = [| "--debug"; "src/A.fs" |] } + Assert.Equal(2, result.CompilerArgs.Length) + Assert.Equal("--debug", result.CompilerArgs.[0]) diff --git a/.github/skills/fsharp-diagnostics/tests/FSharpDiagServer.Tests.fsproj b/.github/skills/fsharp-diagnostics/tests/FSharpDiagServer.Tests.fsproj index eab2ab4ba62..1bc86264dc4 100644 --- a/.github/skills/fsharp-diagnostics/tests/FSharpDiagServer.Tests.fsproj +++ b/.github/skills/fsharp-diagnostics/tests/FSharpDiagServer.Tests.fsproj @@ -19,6 +19,7 @@ + diff --git a/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs b/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs index 5609cc3829b..2a4a885addf 100644 --- a/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs +++ b/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs @@ -98,3 +98,28 @@ let ``InjectTestEntry overwrites existing entry for same normalized path`` () = mgr.InjectTestEntry("/a.fsproj", dummyOptions "/a.fsproj") mgr.InjectTestEntry("/a.fsproj", dummyOptions "/a.fsproj") Assert.Equal(1, mgr.CacheCount) + +[] +let ``Multiple distinct projects coexist in cache`` () = + let mgr = createManager () + mgr.InjectTestEntry("/project1.fsproj", dummyOptions "/project1.fsproj") + mgr.InjectTestEntry("/project2.fsproj", dummyOptions "/project2.fsproj") + mgr.InjectTestEntry("/project3.fsproj", dummyOptions "/project3.fsproj") + Assert.Equal(3, mgr.CacheCount) + Assert.True(mgr.HasCachedProject("/project1.fsproj")) + Assert.True(mgr.HasCachedProject("/project2.fsproj")) + Assert.True(mgr.HasCachedProject("/project3.fsproj")) + +[] +let ``Invalidate specific project preserves others`` () = + let mgr = createManager () + mgr.InjectTestEntry("/x.fsproj", dummyOptions "/x.fsproj") + mgr.InjectTestEntry("/y.fsproj", dummyOptions "/y.fsproj") + mgr.InjectTestEntry("/z.fsproj", dummyOptions "/z.fsproj") + + mgr.Invalidate("/y.fsproj") + + Assert.Equal(2, mgr.CacheCount) + Assert.True(mgr.HasCachedProject("/x.fsproj")) + Assert.False(mgr.HasCachedProject("/y.fsproj")) + Assert.True(mgr.HasCachedProject("/z.fsproj")) diff --git a/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs b/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs index caee848e804..bbb6e8574b5 100644 --- a/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs +++ b/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs @@ -21,3 +21,24 @@ let ``resolveProject routes files to correct fsproj`` (repoRoot: string, filePat let result = resolveProject repoRoot filePath let expected = if expectFcs then fcs repoRoot else componentTests repoRoot Assert.Equal(expected, result) + +[] +let ``resolveProject with trailing slash on repoRoot matches ComponentTests`` () = + let result = resolveProject "/repo/" "/repo/tests/FSharp.Compiler.ComponentTests/X.fs" + Assert.Equal(componentTests "/repo/", result) + +[] +let ``resolveProject defaults to FCS for vsintegration path`` () = + let result = resolveProject "/repo" "/repo/vsintegration/src/FSharpEditor.fs" + Assert.Equal(fcs "/repo", result) + +[] +let ``resolveProject defaults to FCS for FSharp.Core path`` () = + let result = resolveProject "/repo" "/repo/src/FSharp.Core/Array.fs" + Assert.Equal(fcs "/repo", result) + +[] +let ``resolveProject with repoRoot substring in path does not double-strip`` () = + // Ensure that if repoRoot appears as a substring later in the path, it isn't incorrectly stripped + let result = resolveProject "/repo" "/repo/tests/FSharp.Compiler.ComponentTests/repo/Test.fs" + Assert.Equal(componentTests "/repo", result) diff --git a/FSharpBuild.Directory.Build.targets b/FSharpBuild.Directory.Build.targets index 05201df3466..78b25975131 100644 --- a/FSharpBuild.Directory.Build.targets +++ b/FSharpBuild.Directory.Build.targets @@ -159,4 +159,8 @@ + + + From 61e71fee5d62385cc45f301205a17aa6421c0bc3 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 9 Feb 2026 22:14:37 +0100 Subject: [PATCH 13/21] Sprint 5 Fixup: Extract shared normalizeAssemblyRefs, make CompileFromCheckedProject internal CODE-QUALITY: Extract normalizeAssemblyRefs as TcImports.NormalizeAssemblyRef member to eliminate duplication between service.fs and fsc.fs. Make CompileFromCheckedProject internal since it's a dev-loop-only API. Add InternalsVisibleTo for FSharpDiagServer. NO-LEFTOVERS: Remove redundant comment in ResolveProjectTests.fs. Unstage .ralph/ tracking files. Surface area baseline updated to reflect internal visibility change. --- .../fsharp-diagnostics/tests/ResolveProjectTests.fs | 1 - src/Compiler/Driver/CompilerImports.fs | 8 ++++++++ src/Compiler/Driver/CompilerImports.fsi | 3 +++ src/Compiler/Driver/fsc.fs | 7 +------ src/Compiler/FSharp.Compiler.Service.fsproj | 1 + src/Compiler/Service/service.fs | 9 ++------- src/Compiler/Service/service.fsi | 2 +- ...FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl | 1 - 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs b/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs index bbb6e8574b5..11b43b10a46 100644 --- a/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs +++ b/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs @@ -39,6 +39,5 @@ let ``resolveProject defaults to FCS for FSharp.Core path`` () = [] let ``resolveProject with repoRoot substring in path does not double-strip`` () = - // Ensure that if repoRoot appears as a substring later in the path, it isn't incorrectly stripped let result = resolveProject "/repo" "/repo/tests/FSharp.Compiler.ComponentTests/repo/Test.fs" Assert.Equal(componentTests "/repo", result) diff --git a/src/Compiler/Driver/CompilerImports.fs b/src/Compiler/Driver/CompilerImports.fs index dc6d346048b..a5c7ec4f234 100644 --- a/src/Compiler/Driver/CompilerImports.fs +++ b/src/Compiler/Driver/CompilerImports.fs @@ -1385,6 +1385,14 @@ and [] TcImports | Some res -> res | None -> error (Error(FSComp.SR.buildCouldNotResolveAssembly assemblyName, m)) + member tcImports.NormalizeAssemblyRef(ctok, aref: ILAssemblyRef) = + match tcImports.TryFindDllInfo(ctok, rangeStartup, aref.Name, lookupOnly = false) with + | Some dllInfo -> + match dllInfo.ILScopeRef with + | ILScopeRef.Assembly ref -> ref + | _ -> aref + | None -> aref + member _.GetImportedAssemblies() = tciLock.AcquireLock(fun tcitok -> CheckDisposed() diff --git a/src/Compiler/Driver/CompilerImports.fsi b/src/Compiler/Driver/CompilerImports.fsi index 9da0ef71b1d..de6334fd2e6 100644 --- a/src/Compiler/Driver/CompilerImports.fsi +++ b/src/Compiler/Driver/CompilerImports.fsi @@ -166,6 +166,9 @@ type TcImports = member TryFindDllInfo: CompilationThreadToken * range * string * lookupOnly: bool -> ImportedBinary option + /// Normalize an assembly reference by resolving it through the imported assemblies table. + member NormalizeAssemblyRef: CompilationThreadToken * ILAssemblyRef -> ILAssemblyRef + member FindCcuFromAssemblyRef: CompilationThreadToken * range * ILAssemblyRef -> CcuResolutionResult #if !NO_TYPEPROVIDERS diff --git a/src/Compiler/Driver/fsc.fs b/src/Compiler/Driver/fsc.fs index a11231319dd..a7b18f2e78f 100644 --- a/src/Compiler/Driver/fsc.fs +++ b/src/Compiler/Driver/fsc.fs @@ -1101,12 +1101,7 @@ let main6 FileSystem.GetFullPathShim(absolutePath)) let normalizeAssemblyRefs (aref: ILAssemblyRef) = - match tcImports.TryFindDllInfo(ctok, rangeStartup, aref.Name, lookupOnly = false) with - | Some dllInfo -> - match dllInfo.ILScopeRef with - | ILScopeRef.Assembly ref -> ref - | _ -> aref - | None -> aref + tcImports.NormalizeAssemblyRef(ctok, aref) match dynamicAssemblyCreator with | None -> diff --git a/src/Compiler/FSharp.Compiler.Service.fsproj b/src/Compiler/FSharp.Compiler.Service.fsproj index 2f389e3f8f7..1398b69eb9c 100644 --- a/src/Compiler/FSharp.Compiler.Service.fsproj +++ b/src/Compiler/FSharp.Compiler.Service.fsproj @@ -98,6 +98,7 @@ + diff --git a/src/Compiler/Service/service.fs b/src/Compiler/Service/service.fs index c86b9a5055e..f1373a84fa2 100644 --- a/src/Compiler/Service/service.fs +++ b/src/Compiler/Service/service.fs @@ -636,7 +636,7 @@ type FSharpChecker /// Compile a DLL from cached typecheck results, skipping parse/typecheck/optimization. /// For dev-loop use only. Requires keepAssemblyContents=true. /// Returns the output file path on success. - member _.CompileFromCheckedProject(results: FSharpCheckProjectResults, outfile: string) = + member internal _.CompileFromCheckedProject(results: FSharpCheckProjectResults, outfile: string) = async { let tcConfig, tcGlobals, tcImports, generatedCcu, topAttrsOpt, _ilAssemRef, typedImplFilesOpt = results.CompilationData @@ -719,12 +719,7 @@ type FSharpChecker ) let normalizeAssemblyRefs (aref: ILAssemblyRef) = - match tcImports.TryFindDllInfo(ctok, rangeStartup, aref.Name, lookupOnly = false) with - | Some dllInfo -> - match dllInfo.ILScopeRef with - | ILScopeRef.Assembly ref -> ref - | _ -> aref - | None -> aref + tcImports.NormalizeAssemblyRef(ctok, aref) WriteILBinaryFile( { diff --git a/src/Compiler/Service/service.fsi b/src/Compiler/Service/service.fsi index 7d78f39dcd0..0b660d85878 100644 --- a/src/Compiler/Service/service.fsi +++ b/src/Compiler/Service/service.fsi @@ -511,7 +511,7 @@ type public FSharpChecker = /// Compile a DLL from cached typecheck results, skipping parse/typecheck/optimization. /// For dev-loop use only. Requires keepAssemblyContents=true. /// Returns the output file path on success. - member CompileFromCheckedProject: + member internal CompileFromCheckedProject: results: FSharpCheckProjectResults * outfile: string -> Async /// Tokenize a single line, returning token information and a tokenization state represented by an integer diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl index 87b5ad3c4f9..1954ef2367b 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl @@ -2135,7 +2135,6 @@ FSharp.Compiler.CodeAnalysis.FSharpChecker: Microsoft.FSharp.Control.FSharpAsync FSharp.Compiler.CodeAnalysis.FSharpChecker: Microsoft.FSharp.Control.FSharpAsync`1[Microsoft.FSharp.Core.Unit] NotifyProjectCleaned(FSharp.Compiler.CodeAnalysis.FSharpProjectOptions, Microsoft.FSharp.Core.FSharpOption`1[System.String]) FSharp.Compiler.CodeAnalysis.FSharpChecker: Microsoft.FSharp.Control.FSharpAsync`1[System.Collections.Generic.IEnumerable`1[FSharp.Compiler.Text.Range]] FindBackgroundReferencesInFile(System.String, FSharp.Compiler.CodeAnalysis.FSharpProjectOptions, FSharp.Compiler.Symbols.FSharpSymbol, Microsoft.FSharp.Core.FSharpOption`1[System.Boolean], Microsoft.FSharp.Core.FSharpOption`1[System.Boolean], Microsoft.FSharp.Core.FSharpOption`1[System.String]) FSharp.Compiler.CodeAnalysis.FSharpChecker: Microsoft.FSharp.Control.FSharpAsync`1[System.Collections.Generic.IEnumerable`1[FSharp.Compiler.Text.Range]] FindBackgroundReferencesInFile(System.String, FSharpProjectSnapshot, FSharp.Compiler.Symbols.FSharpSymbol, Microsoft.FSharp.Core.FSharpOption`1[System.String]) -FSharp.Compiler.CodeAnalysis.FSharpChecker: Microsoft.FSharp.Control.FSharpAsync`1[System.String] CompileFromCheckedProject(FSharp.Compiler.CodeAnalysis.FSharpCheckProjectResults, System.String) FSharp.Compiler.CodeAnalysis.FSharpChecker: Microsoft.FSharp.Control.FSharpAsync`1[System.Tuple`2[FSharp.Compiler.CodeAnalysis.FSharpParseFileResults,FSharp.Compiler.CodeAnalysis.FSharpCheckFileAnswer]] ParseAndCheckFileInProject(System.String, FSharpProjectSnapshot, Microsoft.FSharp.Core.FSharpOption`1[System.String]) FSharp.Compiler.CodeAnalysis.FSharpChecker: Microsoft.FSharp.Control.FSharpAsync`1[System.Tuple`2[FSharp.Compiler.CodeAnalysis.FSharpParseFileResults,FSharp.Compiler.CodeAnalysis.FSharpCheckFileAnswer]] ParseAndCheckFileInProject(System.String, Int32, FSharp.Compiler.Text.ISourceText, FSharp.Compiler.CodeAnalysis.FSharpProjectOptions, Microsoft.FSharp.Core.FSharpOption`1[System.String]) FSharp.Compiler.CodeAnalysis.FSharpChecker: Microsoft.FSharp.Control.FSharpAsync`1[System.Tuple`2[FSharp.Compiler.CodeAnalysis.FSharpParseFileResults,FSharp.Compiler.CodeAnalysis.FSharpCheckFileResults]] GetBackgroundCheckResultsForFileInProject(System.String, FSharp.Compiler.CodeAnalysis.FSharpProjectOptions, Microsoft.FSharp.Core.FSharpOption`1[System.String]) From 651a188486b186e378c696c1f1e060dc13e4ab30 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 9 Feb 2026 22:58:38 +0100 Subject: [PATCH 14/21] Sprint 6 Fixup: Address CODE-QUALITY, HONEST-ASSESSMENT, NO-LEFTOVERS, TEST-CODE-QUALITY, TEST-COVERAGE verifier feedback - CODE-QUALITY: Break long line in Server.fs, use InvalidOperationException instead of failwith in CompileFromCheckedProject, add project-not-found validation in compile handler, add caching semantics comment - HONEST-ASSESSMENT: Update SKILL.md to reflect ComponentTests support - NO-LEFTOVERS: Remove unused InternalsVisibleTo from FSharpDiagServer.fsproj, update shell script usage/header to include --compile flag - TEST-CODE-QUALITY: Consolidate duplicate Fact tests into Theory InlineData, add meaningful DesignTimeBuildTests for config overrides and edge cases - TEST-COVERAGE: Add ResolveProjectOptions error path test, add FCS fallback and ComponentTests boundary routing tests, add DtbConfig edge cases --- .github/skills/fsharp-diagnostics/SKILL.md | 6 ++-- .../scripts/get-fsharp-errors.sh | 2 ++ .../server/FSharpDiagServer.fsproj | 4 --- .../fsharp-diagnostics/server/Server.fs | 8 +++++- .../tests/DesignTimeBuildTests.fs | 16 +++++++++++ .../tests/ProjectManagerTests.fs | 25 ++++++++--------- .../tests/ResolveProjectTests.fs | 28 ++++++++----------- src/Compiler/Service/service.fs | 5 ++-- 8 files changed, 55 insertions(+), 39 deletions(-) diff --git a/.github/skills/fsharp-diagnostics/SKILL.md b/.github/skills/fsharp-diagnostics/SKILL.md index 33d3034bf2f..ae1295fbf18 100644 --- a/.github/skills/fsharp-diagnostics/SKILL.md +++ b/.github/skills/fsharp-diagnostics/SKILL.md @@ -37,13 +37,13 @@ To see inferred types as inline `// (name: Type)` comments: GetErrors --type-hints src/Compiler/TypedTree/TypedTreeOps.fs 1028 1032 ``` -## Running tests faster (FSharp.Compiler.Service only) +## Running tests faster -After checking errors are clean, run tests with cached compilation (skips full recompile of FSharp.Compiler.Service): +After checking errors are clean, run tests with cached compilation (skips full recompile of FSharp.Compiler.Service and FSharp.Compiler.ComponentTests): ```bash dotnet test tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj -c Release /p:FastBuildFromCache=true ``` -Only affects FSharp.Compiler.Service.fsproj build. All other projects build normally. Falls back to normal build if server is unavailable. +All other projects build normally. Falls back to normal build if server is unavailable. ## Other diff --git a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh index 2bd9cf9e5b2..c5e1914d2b2 100755 --- a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh +++ b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh @@ -5,6 +5,7 @@ set -euo pipefail # Usage: # get-fsharp-errors.sh [--parse-only] # get-fsharp-errors.sh --check-project +# get-fsharp-errors.sh --compile # get-fsharp-errors.sh --ping # get-fsharp-errors.sh --shutdown @@ -114,6 +115,7 @@ case "${1:-}" in -*) echo "Usage: get-fsharp-errors [--parse-only] " >&2 echo " get-fsharp-errors --check-project " >&2 + echo " get-fsharp-errors --compile " >&2 echo " get-fsharp-errors --ping | --shutdown" >&2 exit 1 ;; diff --git a/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj b/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj index 37b8f562cf4..61c33e785ef 100644 --- a/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj +++ b/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj @@ -27,10 +27,6 @@ - - - - diff --git a/.github/skills/fsharp-diagnostics/server/Server.fs b/.github/skills/fsharp-diagnostics/server/Server.fs index f0e9ddc9bb9..375995cd7a9 100644 --- a/.github/skills/fsharp-diagnostics/server/Server.fs +++ b/.github/skills/fsharp-diagnostics/server/Server.fs @@ -37,7 +37,9 @@ let startServer (config: ServerConfig) = let mutable lastActivity = DateTimeOffset.UtcNow let cts = new CancellationTokenSource() - let getOptions (filePath: string) = projectMgr.ResolveProjectOptions(ProjectRouting.resolveProject config.RepoRoot filePath) + let getOptions (filePath: string) = + let fsproj = ProjectRouting.resolveProject config.RepoRoot filePath + projectMgr.ResolveProjectOptions(fsproj) let handleRequest (json: string) = async { @@ -212,11 +214,15 @@ let startServer (config: ServerConfig) = | "compile" -> let project = doc.RootElement.GetProperty("project").GetString() let output = doc.RootElement.GetProperty("output").GetString() + if not (File.Exists project) then + return $"ERROR: project not found: {project}" + else let! optionsResult = projectMgr.ResolveProjectOptions(project) match optionsResult with | Error msg -> return $"ERROR: {msg}" | Ok options -> + // FSharpChecker caches project results; repeated calls return cached data. let! results = checker.ParseAndCheckProject(options) if results.HasCriticalErrors then let diags = DiagnosticsFormatter.formatProject config.RepoRoot results.Diagnostics diff --git a/.github/skills/fsharp-diagnostics/tests/DesignTimeBuildTests.fs b/.github/skills/fsharp-diagnostics/tests/DesignTimeBuildTests.fs index 7d3a019ee95..9653a15e3be 100644 --- a/.github/skills/fsharp-diagnostics/tests/DesignTimeBuildTests.fs +++ b/.github/skills/fsharp-diagnostics/tests/DesignTimeBuildTests.fs @@ -13,3 +13,19 @@ let ``DtbResult can hold compiler args`` () = let result = { CompilerArgs = [| "--debug"; "src/A.fs" |] } Assert.Equal(2, result.CompilerArgs.Length) Assert.Equal("--debug", result.CompilerArgs.[0]) + +[] +let ``custom DtbConfig overrides default values`` () = + let config = { TargetFramework = Some "net8.0"; Configuration = "Debug" } + Assert.Equal(Some "net8.0", config.TargetFramework) + Assert.Equal("Debug", config.Configuration) + +[] +let ``DtbConfig with no TargetFramework`` () = + let config = { TargetFramework = None; Configuration = "Release" } + Assert.Equal(None, config.TargetFramework) + +[] +let ``DtbResult with empty CompilerArgs`` () = + let result = { CompilerArgs = [||] } + Assert.Empty(result.CompilerArgs) diff --git a/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs b/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs index 2a4a885addf..ac4c0943a8e 100644 --- a/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs +++ b/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs @@ -33,19 +33,6 @@ let ``InjectTestEntry populates cache`` () = Assert.Equal(1, mgr.CacheCount) Assert.True(mgr.HasCachedProject("/a.fsproj")) -[] -let ``Invalidate specific path removes only that project`` () = - let mgr = createManager () - mgr.InjectTestEntry("/a.fsproj", dummyOptions "/a.fsproj") - mgr.InjectTestEntry("/b.fsproj", dummyOptions "/b.fsproj") - Assert.Equal(2, mgr.CacheCount) - - mgr.Invalidate("/a.fsproj") - - Assert.Equal(1, mgr.CacheCount) - Assert.False(mgr.HasCachedProject("/a.fsproj")) - Assert.True(mgr.HasCachedProject("/b.fsproj")) - [] let ``Invalidate all clears entire cache`` () = let mgr = createManager () @@ -123,3 +110,15 @@ let ``Invalidate specific project preserves others`` () = Assert.True(mgr.HasCachedProject("/x.fsproj")) Assert.False(mgr.HasCachedProject("/y.fsproj")) Assert.True(mgr.HasCachedProject("/z.fsproj")) + +[] +let ``ResolveProjectOptions returns Error for nonexistent project`` () = + let mgr = createManager () + // DTB run will throw because the working directory doesn't exist; + // verify the error propagates without crashing the manager. + let ex = + Assert.ThrowsAny(fun () -> + mgr.ResolveProjectOptions("/nonexistent/path/project.fsproj") + |> Async.RunSynchronously + |> ignore) + Assert.NotNull(ex) diff --git a/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs b/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs index 11b43b10a46..d817d3f7018 100644 --- a/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs +++ b/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs @@ -17,27 +17,23 @@ let private componentTests root = [] [] [] +[] +[] +// Edge case: "repo" substring inside ComponentTests path should not confuse stripping +[] let ``resolveProject routes files to correct fsproj`` (repoRoot: string, filePath: string, expectFcs: bool) = let result = resolveProject repoRoot filePath let expected = if expectFcs then fcs repoRoot else componentTests repoRoot Assert.Equal(expected, result) [] -let ``resolveProject with trailing slash on repoRoot matches ComponentTests`` () = - let result = resolveProject "/repo/" "/repo/tests/FSharp.Compiler.ComponentTests/X.fs" - Assert.Equal(componentTests "/repo/", result) +let ``resolveProject handles exact ComponentTests boundary`` () = + // File directly in ComponentTests root + let result = resolveProject "/repo" "/repo/tests/FSharp.Compiler.ComponentTests/SomeTest.fs" + Assert.Contains("FSharp.Compiler.ComponentTests.fsproj", result) [] -let ``resolveProject defaults to FCS for vsintegration path`` () = - let result = resolveProject "/repo" "/repo/vsintegration/src/FSharpEditor.fs" - Assert.Equal(fcs "/repo", result) - -[] -let ``resolveProject defaults to FCS for FSharp.Core path`` () = - let result = resolveProject "/repo" "/repo/src/FSharp.Core/Array.fs" - Assert.Equal(fcs "/repo", result) - -[] -let ``resolveProject with repoRoot substring in path does not double-strip`` () = - let result = resolveProject "/repo" "/repo/tests/FSharp.Compiler.ComponentTests/repo/Test.fs" - Assert.Equal(componentTests "/repo", result) +let ``resolveProject falls back to FCS for other test projects`` () = + // Non-ComponentTests test project should go to FCS + let result = resolveProject "/repo" "/repo/tests/FSharp.Core.UnitTests/SomeTest.fs" + Assert.Contains("FSharp.Compiler.Service.fsproj", result) diff --git a/src/Compiler/Service/service.fs b/src/Compiler/Service/service.fs index f1373a84fa2..5ff606fef10 100644 --- a/src/Compiler/Service/service.fs +++ b/src/Compiler/Service/service.fs @@ -644,12 +644,13 @@ type FSharpChecker let topAttrs = match topAttrsOpt with | Some a -> a - | None -> failwith "CompileFromCheckedProject: no top attributes available" + | None -> raise (InvalidOperationException "CompileFromCheckedProject: no top attributes available") let typedImplFiles = match typedImplFilesOpt with | Some files -> files - | None -> failwith "CompileFromCheckedProject: keepAssemblyContents must be true" + | None -> + raise (InvalidOperationException "CompileFromCheckedProject: keepAssemblyContents must be true") // Save and restore CCU attribs to prevent quadratic growth on repeated compile calls. let originalAttribs = generatedCcu.Contents.Attribs From 0a3fa44645ca17d646ac538ec030f083a254c2a8 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 9 Feb 2026 23:35:58 +0100 Subject: [PATCH 15/21] Sprint 7 Fixup: Address PERF and TEST-COVERAGE verifier feedback PERF: - Replace Dictionary+lock with ConcurrentDictionary in ProjectManager to eliminate lock contention on concurrent cache lookups - Use HashSet instead of ResizeArray for O(1) symbol name lookup in findRefs - Avoid Array.append allocation when one diagnostics array is empty TEST-COVERAGE: - Add concurrent InjectTestEntry+Invalidate thread safety test - Add concurrent InjectTestEntry from multiple threads test - Add Invalidate-during-concurrent-reads test - Add error-does-not-pollute-cache test - Include leftover test consolidation from Fixup #2 --- .../server/ProjectManager.fs | 30 +++++----- .../fsharp-diagnostics/server/Server.fs | 12 ++-- .../tests/DesignTimeBuildTests.fs | 17 +++--- .../tests/ProjectManagerTests.fs | 59 +++++++++++++++++++ .../tests/ResolveProjectTests.fs | 14 +---- 5 files changed, 89 insertions(+), 43 deletions(-) diff --git a/.github/skills/fsharp-diagnostics/server/ProjectManager.fs b/.github/skills/fsharp-diagnostics/server/ProjectManager.fs index a292e1848bf..79de11ed018 100644 --- a/.github/skills/fsharp-diagnostics/server/ProjectManager.fs +++ b/.github/skills/fsharp-diagnostics/server/ProjectManager.fs @@ -1,12 +1,11 @@ module FSharpDiagServer.ProjectManager -open System.Collections.Generic +open System.Collections.Concurrent open System.IO open FSharp.Compiler.CodeAnalysis type ProjectManager(checker: FSharpChecker) = - let cache = Dictionary() - let gate = obj () + let cache = ConcurrentDictionary() let isSourceFile (s: string) = not (s.StartsWith("-")) @@ -19,12 +18,12 @@ type ProjectManager(checker: FSharpChecker) = async { let key = normalize fsprojPath let fsprojMtime = File.GetLastWriteTimeUtc(key) + let current = - lock gate (fun () -> - match cache.TryGetValue(key) with - | true, (mtime, opts) when mtime = fsprojMtime -> Some opts - | true, _ -> cache.Remove(key) |> ignore; None - | false, _ -> None) + match cache.TryGetValue(key) with + | true, (mtime, opts) when mtime = fsprojMtime -> Some opts + | true, _ -> cache.TryRemove(key) |> ignore; None + | false, _ -> None match current with | Some opts -> return Ok opts @@ -47,21 +46,20 @@ type ProjectManager(checker: FSharpChecker) = let flagsOnly = resolvedArgs |> Array.filter (not << isSourceFile) let opts = checker.GetProjectOptionsFromCommandLineArgs(key, flagsOnly) let options = { opts with SourceFiles = sourceFiles } - lock gate (fun () -> cache.[key] <- (fsprojMtime, options)) + cache.[key] <- (fsprojMtime, options) return Ok options } member _.Invalidate(?fsprojPath: string) = - lock gate (fun () -> - match fsprojPath with - | Some p -> cache.Remove(normalize p) |> ignore - | None -> cache.Clear()) + match fsprojPath with + | Some p -> cache.TryRemove(normalize p) |> ignore + | None -> cache.Clear() - member internal _.CacheCount = lock gate (fun () -> cache.Count) + member internal _.CacheCount = cache.Count member internal _.HasCachedProject(fsprojPath: string) = - lock gate (fun () -> cache.ContainsKey(normalize fsprojPath)) + cache.ContainsKey(normalize fsprojPath) member internal _.InjectTestEntry(fsprojPath: string, options: FSharpProjectOptions) = let key = normalize fsprojPath - lock gate (fun () -> cache.[key] <- (System.DateTime.MinValue, options)) + cache.[key] <- (System.DateTime.MinValue, options) diff --git a/.github/skills/fsharp-diagnostics/server/Server.fs b/.github/skills/fsharp-diagnostics/server/Server.fs index 375995cd7a9..dbd9f078247 100644 --- a/.github/skills/fsharp-diagnostics/server/Server.fs +++ b/.github/skills/fsharp-diagnostics/server/Server.fs @@ -83,7 +83,10 @@ let startServer (config: ServerConfig) = let! parseResults, checkAnswer = checker.ParseAndCheckFileInProject(file, version, sourceText, options) let diags = match checkAnswer with - | FSharpCheckFileAnswer.Succeeded r -> Array.append parseResults.Diagnostics r.Diagnostics + | FSharpCheckFileAnswer.Succeeded r -> + if parseResults.Diagnostics.Length = 0 then r.Diagnostics + elif r.Diagnostics.Length = 0 then parseResults.Diagnostics + else Array.append parseResults.Diagnostics r.Diagnostics | FSharpCheckFileAnswer.Aborted -> parseResults.Diagnostics |> Array.distinctBy (fun d -> d.StartLine, d.Start.Column, d.ErrorNumberText) return DiagnosticsFormatter.formatFile diags @@ -134,11 +137,11 @@ let startServer (config: ServerConfig) = | Some symbolUse -> let! projectResults = checker.ParseAndCheckProject(options) // Collect related symbols: for DU types, also search union cases - let targetNames = ResizeArray() - targetNames.Add(symbolUse.Symbol.FullName) + let targetNames = System.Collections.Generic.HashSet() + targetNames.Add(symbolUse.Symbol.FullName) |> ignore match symbolUse.Symbol with | :? FSharpEntity as ent when ent.IsFSharpUnion -> - for uc in ent.UnionCases do targetNames.Add(uc.FullName) + for uc in ent.UnionCases do targetNames.Add(uc.FullName) |> ignore | _ -> () let uses = projectResults.GetAllUsesOfAllSymbols() @@ -222,7 +225,6 @@ let startServer (config: ServerConfig) = | Error msg -> return $"ERROR: {msg}" | Ok options -> - // FSharpChecker caches project results; repeated calls return cached data. let! results = checker.ParseAndCheckProject(options) if results.HasCriticalErrors then let diags = DiagnosticsFormatter.formatProject config.RepoRoot results.Diagnostics diff --git a/.github/skills/fsharp-diagnostics/tests/DesignTimeBuildTests.fs b/.github/skills/fsharp-diagnostics/tests/DesignTimeBuildTests.fs index 9653a15e3be..cc992d64691 100644 --- a/.github/skills/fsharp-diagnostics/tests/DesignTimeBuildTests.fs +++ b/.github/skills/fsharp-diagnostics/tests/DesignTimeBuildTests.fs @@ -14,16 +14,13 @@ let ``DtbResult can hold compiler args`` () = Assert.Equal(2, result.CompilerArgs.Length) Assert.Equal("--debug", result.CompilerArgs.[0]) -[] -let ``custom DtbConfig overrides default values`` () = - let config = { TargetFramework = Some "net8.0"; Configuration = "Debug" } - Assert.Equal(Some "net8.0", config.TargetFramework) - Assert.Equal("Debug", config.Configuration) - -[] -let ``DtbConfig with no TargetFramework`` () = - let config = { TargetFramework = None; Configuration = "Release" } - Assert.Equal(None, config.TargetFramework) +[] +[] +[] +let ``DtbConfig construction preserves values`` (tfm: string, cfg: string) = + let config = { TargetFramework = Option.ofObj tfm; Configuration = cfg } + Assert.Equal(Option.ofObj tfm, config.TargetFramework) + Assert.Equal(cfg, config.Configuration) [] let ``DtbResult with empty CompilerArgs`` () = diff --git a/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs b/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs index ac4c0943a8e..5eb7d088bab 100644 --- a/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs +++ b/.github/skills/fsharp-diagnostics/tests/ProjectManagerTests.fs @@ -122,3 +122,62 @@ let ``ResolveProjectOptions returns Error for nonexistent project`` () = |> Async.RunSynchronously |> ignore) Assert.NotNull(ex) + +[] +let ``Concurrent InjectTestEntry and Invalidate do not corrupt cache`` () = + let mgr = createManager () + let iterations = 100 + let tasks = + [| for i in 0 .. iterations - 1 do + async { + let path = $"/concurrent_{i}.fsproj" + mgr.InjectTestEntry(path, dummyOptions path) + mgr.Invalidate(path) + } |] + tasks |> Async.Parallel |> Async.RunSynchronously |> ignore + // After all inject+invalidate pairs, cache should be empty + Assert.Equal(0, mgr.CacheCount) + +[] +let ``Concurrent InjectTestEntry from multiple threads`` () = + let mgr = createManager () + let count = 50 + let tasks = + [| for i in 0 .. count - 1 do + async { + let path = $"/parallel_{i}.fsproj" + mgr.InjectTestEntry(path, dummyOptions path) + } |] + tasks |> Async.Parallel |> Async.RunSynchronously |> ignore + Assert.Equal(count, mgr.CacheCount) + +[] +let ``Invalidate specific during concurrent reads preserves other entries`` () = + let mgr = createManager () + for i in 0 .. 9 do + mgr.InjectTestEntry($"/stable_{i}.fsproj", dummyOptions $"/stable_{i}.fsproj") + let tasks = + [| for i in 0 .. 4 do + async { + mgr.Invalidate($"/stable_{i}.fsproj") + } + for i in 5 .. 9 do + async { + Assert.True(mgr.HasCachedProject($"/stable_{i}.fsproj")) + } |] + tasks |> Async.Parallel |> Async.RunSynchronously |> ignore + // First 5 removed, last 5 remain + Assert.Equal(5, mgr.CacheCount) + +[] +let ``ResolveProjectOptions error does not pollute cache`` () = + let mgr = createManager () + mgr.InjectTestEntry("/good.fsproj", dummyOptions "/good.fsproj") + // Attempting to resolve a nonexistent project should not affect existing cache + try + mgr.ResolveProjectOptions("/nonexistent/bad.fsproj") + |> Async.RunSynchronously + |> ignore + with _ -> () + Assert.Equal(1, mgr.CacheCount) + Assert.True(mgr.HasCachedProject("/good.fsproj")) diff --git a/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs b/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs index d817d3f7018..533b8d8f333 100644 --- a/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs +++ b/.github/skills/fsharp-diagnostics/tests/ResolveProjectTests.fs @@ -21,19 +21,9 @@ let private componentTests root = [] // Edge case: "repo" substring inside ComponentTests path should not confuse stripping [] +// Non-ComponentTests test project should fall back to FCS +[] let ``resolveProject routes files to correct fsproj`` (repoRoot: string, filePath: string, expectFcs: bool) = let result = resolveProject repoRoot filePath let expected = if expectFcs then fcs repoRoot else componentTests repoRoot Assert.Equal(expected, result) - -[] -let ``resolveProject handles exact ComponentTests boundary`` () = - // File directly in ComponentTests root - let result = resolveProject "/repo" "/repo/tests/FSharp.Compiler.ComponentTests/SomeTest.fs" - Assert.Contains("FSharp.Compiler.ComponentTests.fsproj", result) - -[] -let ``resolveProject falls back to FCS for other test projects`` () = - // Non-ComponentTests test project should go to FCS - let result = resolveProject "/repo" "/repo/tests/FSharp.Core.UnitTests/SomeTest.fs" - Assert.Contains("FSharp.Compiler.Service.fsproj", result) From 86389f7664bfd4638a211bf302a911a09e1aa0d9 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Wed, 11 Feb 2026 22:28:54 +0100 Subject: [PATCH 16/21] FastBuildFromCache: minimal optimizer, NormalizeAssemblyRef internal, filewatcher pre-warming, --times profiling - NormalizeAssemblyRef on TcImports is now member internal (no public API change) - CompileFromCheckedProject uses minimal optimizer with mandatory lowering passes (OptimizeImplFile + LowerLocalMutables + LowerCalls, no detuple/TLR/extra loops) - Added ReportTime instrumentation for --times profiling of emit phases - FastBuildFromCache.targets: added Inputs/Outputs mirroring CoreCompile for proper MSBuild incremental skip, touch all CoreCompile outputs after cache emit - Diagnostics server: filewatcher pre-warming with 5s throttle on src/Compiler/ - Diagnostics server: compile handler with --compile flag, DTB caching, resource embedding --- .../scripts/get-fsharp-errors.sh | 4 +- .../server/DesignTimeBuild.fs | 63 +++++++- .../server/Directory.Build.props | 3 + .../server/FSharpDiagServer.fsproj | 25 +-- .../server/ProjectManager.fs | 41 ++++- .../fsharp-diagnostics/server/Server.fs | 69 ++++++++- eng/targets/FastBuildFromCache.targets | 124 +++++++++++---- src/Compiler/Driver/CompilerImports.fs | 2 +- src/Compiler/Driver/CompilerImports.fsi | 2 +- src/Compiler/Service/FSharpCheckerResults.fs | 4 +- src/Compiler/Service/FSharpCheckerResults.fsi | 2 +- src/Compiler/Service/service.fs | 144 ++++++++++++++---- 12 files changed, 404 insertions(+), 79 deletions(-) diff --git a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh index c5e1914d2b2..731abcf30ca 100755 --- a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh +++ b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh @@ -110,7 +110,9 @@ case "${1:-}" in PROJECT="$1" OUTPUT="$2" ensure_server "$REPO_ROOT" "$SOCK_PATH" - send_request "$SOCK_PATH" "{\"command\":\"compile\",\"project\":\"$PROJECT\",\"output\":\"$OUTPUT\"}" + RESPONSE=$(send_request "$SOCK_PATH" "{\"command\":\"compile\",\"project\":\"$PROJECT\",\"output\":\"$OUTPUT\"}") + echo "$RESPONSE" + case "$RESPONSE" in ERROR*) exit 1 ;; esac ;; -*) echo "Usage: get-fsharp-errors [--parse-only] " >&2 diff --git a/.github/skills/fsharp-diagnostics/server/DesignTimeBuild.fs b/.github/skills/fsharp-diagnostics/server/DesignTimeBuild.fs index 0baf15e1b71..d367b8f59a9 100644 --- a/.github/skills/fsharp-diagnostics/server/DesignTimeBuild.fs +++ b/.github/skills/fsharp-diagnostics/server/DesignTimeBuild.fs @@ -6,7 +6,8 @@ open System.IO open System.Text.Json type DtbResult = - { CompilerArgs: string array } + { CompilerArgs: string array + IntermediateOutputPath: string } type DtbConfig = { TargetFramework: string option @@ -24,15 +25,49 @@ let run (fsprojPath: string) (config: DtbConfig) = |> Option.defaultValue "" let projDir = Path.GetDirectoryName(fsprojPath) + let projName = Path.GetFileNameWithoutExtension(fsprojPath) - // /t:Build runs BeforeBuild (generates buildproperties.fs via CompileBefore). - // DesignTimeBuild=true skips dependency projects. - // SkipCompilerExecution=true + ProvideCommandLineArgs=true populates FscCommandLineArgs without compiling. + // Query IntermediateOutputPath to find and delete the intermediate assembly, + // defeating MSBuild's up-to-date check so CoreCompile actually runs. + let iopPsi = + ProcessStartInfo( + FileName = "dotnet", + Arguments = + $"msbuild \"{fsprojPath}\" /p:BUILDING_USING_DOTNET=true /p:Configuration={config.Configuration}{tfmArg} /nologo /v:q /getProperty:IntermediateOutputPath", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WorkingDirectory = projDir + ) + use iopProc = Process.Start(iopPsi) + let! iopOut = iopProc.StandardOutput.ReadToEndAsync() |> Async.AwaitTask + do! iopProc.WaitForExitAsync() |> Async.AwaitTask + + let iopOut = iopOut.Trim() + // Handle both plain path and JSON output from /getProperty + let intermediateDir = + if iopOut.StartsWith("{") then + try + let doc = JsonDocument.Parse(iopOut) + doc.RootElement.GetProperty("Properties").GetProperty("IntermediateOutputPath").GetString() + with _ -> "" + else iopOut + let intermediateDir = + if Path.IsPathRooted(intermediateDir) then intermediateDir + elif intermediateDir.Length > 0 then Path.Combine(projDir, intermediateDir) + else "" + if intermediateDir.Length > 0 then + let intermediateDll = Path.Combine(intermediateDir, projName + ".dll") + if File.Exists(intermediateDll) then + try File.Delete(intermediateDll) with _ -> () + + // /t:CoreCompile + SkipCompilerExecution + ProvideCommandLineArgs populates FscCommandLineArgs. + // BuildProjectReferences=false avoids rebuilding dependencies. let psi = ProcessStartInfo( FileName = "dotnet", Arguments = - $"msbuild \"{fsprojPath}\" /t:Build /p:DesignTimeBuild=true /p:SkipCompilerExecution=true /p:ProvideCommandLineArgs=true /p:CopyBuildOutputToOutputDirectory=false /p:CopyOutputSymbolsToOutputDirectory=false /p:BUILDING_USING_DOTNET=true /p:Configuration={config.Configuration}{tfmArg} /nologo /v:q /getItem:FscCommandLineArgs", + $"msbuild \"{fsprojPath}\" /t:CoreCompile /p:SkipCompilerExecution=true /p:ProvideCommandLineArgs=true /p:CopyBuildOutputToOutputDirectory=false /p:CopyOutputSymbolsToOutputDirectory=false /p:BUILDING_USING_DOTNET=true /p:BuildProjectReferences=false /p:Configuration={config.Configuration}{tfmArg} /nologo /v:q \"/getItem:FscCommandLineArgs;ReferencePath\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -48,7 +83,6 @@ let run (fsprojPath: string) (config: DtbConfig) = return Error $"DTB failed (exit {proc.ExitCode}): {stderr}" else try - // MSBuild may emit warnings before the JSON; find the JSON start let jsonStart = stdout.IndexOf('{') if jsonStart < 0 then return Error $"No JSON in DTB output: {stdout.[..200]}" @@ -61,7 +95,22 @@ let run (fsprojPath: string) (config: DtbConfig) = |> Seq.map (fun e -> e.GetProperty("Identity").GetString()) |> Seq.toArray - return Ok { CompilerArgs = args } + let refs = + match items.TryGetProperty("ReferencePath") with + | true, refItems -> + refItems.EnumerateArray() + |> Seq.map (fun e -> + let path = e.GetProperty("Identity").GetString() + "-r:" + path) + |> Seq.toArray + | false, _ -> [||] + + let combined = Array.append args refs + + if args.Length = 0 then + return Error "DTB returned empty FscCommandLineArgs (CoreCompile was skipped)" + else + return Ok { CompilerArgs = combined; IntermediateOutputPath = intermediateDir } with ex -> return Error $"Failed to parse DTB output: {ex.Message}" } diff --git a/.github/skills/fsharp-diagnostics/server/Directory.Build.props b/.github/skills/fsharp-diagnostics/server/Directory.Build.props index 5a08e96c89f..aee071495f5 100644 --- a/.github/skills/fsharp-diagnostics/server/Directory.Build.props +++ b/.github/skills/fsharp-diagnostics/server/Directory.Build.props @@ -5,5 +5,8 @@ false $(MSBuildThisFileDirectory)../../../../.tools/fsharp-diag/bin/ $(MSBuildThisFileDirectory)../../../../.tools/fsharp-diag/obj/ + true + true + $(MSBuildThisFileDirectory)../../../../buildtools/keys/MSFT.snk diff --git a/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj b/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj index aaf79def15f..163b3e9d2c3 100644 --- a/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj +++ b/.github/skills/fsharp-diagnostics/server/FSharpDiagServer.fsproj @@ -5,29 +5,32 @@ net10.0 <_DiagServerRepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)', '..', '..', '..', '..')) + + <_ReleaseFcsPath>$(_DiagServerRepoRoot)artifacts/bin/FSharp.Compiler.Service/Release/net10.0/FSharp.Compiler.Service.dll + <_ProtoFcsPath>$(_DiagServerRepoRoot)artifacts/Bootstrap/fsc/FSharp.Compiler.Service.dll - - + + + + $(_ReleaseFcsPath) + + + + + $(_ProtoFcsPath) - - + + - - - - diff --git a/.github/skills/fsharp-diagnostics/server/ProjectManager.fs b/.github/skills/fsharp-diagnostics/server/ProjectManager.fs index 79de11ed018..5a06ff0b6b6 100644 --- a/.github/skills/fsharp-diagnostics/server/ProjectManager.fs +++ b/.github/skills/fsharp-diagnostics/server/ProjectManager.fs @@ -43,9 +43,48 @@ type ProjectManager(checker: FSharpChecker) = |> Array.map (fun a -> if isSourceFile a then resolve a else a) let sourceFiles = resolvedArgs |> Array.filter isSourceFile + + // MSBuild auto-generates AssemblyInfo.fs and buildproperties.fs in the + // intermediate output. These are added to @(Compile) but NOT to + // FscCommandLineArgs. Include them if they exist and aren't already present. + let extraGeneratedFiles = + if dtb.IntermediateOutputPath.Length > 0 then + let projName = Path.GetFileNameWithoutExtension(key) + [| Path.Combine(dtb.IntermediateOutputPath, projName + ".AssemblyInfo.fs") + Path.Combine(dtb.IntermediateOutputPath, "buildproperties.fs") |] + |> Array.filter (fun f -> + File.Exists(f) && + let full = Path.GetFullPath(f) + not (sourceFiles |> Array.exists (fun s -> Path.GetFullPath(s) = full))) + else [||] + let sourceFiles = Array.append sourceFiles extraGeneratedFiles + + // Generated string resource files (FSComp, FSIstrings, UtilsStrings) must come + // before source files that reference them. FscCommandLineArgs doesn't preserve + // the CompileBefore ordering from MSBuild. + let isGeneratedFirst (s: string) = + let name = System.IO.Path.GetFileNameWithoutExtension(s) + name = "FSComp" || name = "FSIstrings" || name = "UtilsStrings" + || name = "buildproperties" + let orderedSources = + Array.append + (sourceFiles |> Array.filter isGeneratedFirst) + (sourceFiles |> Array.filter (not << isGeneratedFirst)) let flagsOnly = resolvedArgs |> Array.filter (not << isSourceFile) + // Add --nowin32manifest: default.win32manifest may not exist on all platforms + let flagsOnly = Array.append flagsOnly [| "--nowin32manifest" |] + // Embed pre-compiled .resources files from intermediate output. + // These are generated by CoreResGen from .resx files (which come from + // FSComp.txt, FSIstrings.txt, etc.). They only change when the .txt + // source files change, so reusing them from the intermediate directory is safe. + let resourceFlags = + if dtb.IntermediateOutputPath.Length > 0 then + System.IO.Directory.GetFiles(dtb.IntermediateOutputPath, "*.resources") + |> Array.map (fun r -> "--resource:" + r) + else [||] + let flagsOnly = Array.append flagsOnly resourceFlags let opts = checker.GetProjectOptionsFromCommandLineArgs(key, flagsOnly) - let options = { opts with SourceFiles = sourceFiles } + let options = { opts with SourceFiles = orderedSources } cache.[key] <- (fsprojMtime, options) return Ok options } diff --git a/.github/skills/fsharp-diagnostics/server/Server.fs b/.github/skills/fsharp-diagnostics/server/Server.fs index dbd9f078247..096a0a401a8 100644 --- a/.github/skills/fsharp-diagnostics/server/Server.fs +++ b/.github/skills/fsharp-diagnostics/server/Server.fs @@ -37,6 +37,9 @@ let startServer (config: ServerConfig) = let mutable lastActivity = DateTimeOffset.UtcNow let cts = new CancellationTokenSource() + // Enable --times output from F# compiler phases (Activity-based profiling) + use _timesListener = FSharp.Compiler.Diagnostics.Activity.Profiling.addConsoleListener () + let getOptions (filePath: string) = let fsproj = ProjectRouting.resolveProject config.RepoRoot filePath projectMgr.ResolveProjectOptions(fsproj) @@ -94,7 +97,10 @@ let startServer (config: ServerConfig) = | "checkProject" -> let project = match doc.RootElement.TryGetProperty("project") with - | true, p -> p.GetString() + | true, p -> + let raw = p.GetString() + if Path.IsPathRooted(raw) then raw + else Path.GetFullPath(Path.Combine(config.RepoRoot, raw)) | false, _ -> Path.Combine(config.RepoRoot, "src/Compiler/FSharp.Compiler.Service.fsproj") let! optionsResult = projectMgr.ResolveProjectOptions(project) match optionsResult with @@ -220,18 +226,25 @@ let startServer (config: ServerConfig) = if not (File.Exists project) then return $"ERROR: project not found: {project}" else + let sw = System.Diagnostics.Stopwatch.StartNew() let! optionsResult = projectMgr.ResolveProjectOptions(project) + let dtbTime = sw.Elapsed.TotalMilliseconds match optionsResult with | Error msg -> return $"ERROR: {msg}" | Ok options -> + sw.Restart() let! results = checker.ParseAndCheckProject(options) + let checkTime = sw.Elapsed.TotalMilliseconds if results.HasCriticalErrors then let diags = DiagnosticsFormatter.formatProject config.RepoRoot results.Diagnostics return $"ERROR: Project has errors:\n{diags}" else try + sw.Restart() let! _ = checker.CompileFromCheckedProject(results, output) + let emitTime = sw.Elapsed.TotalMilliseconds + eprintfn $"[fsharp-diag] compile: DTB={dtbTime:F0}ms Check={checkTime:F0}ms Emit={emitTime:F0}ms Total={dtbTime+checkTime+emitTime:F0}ms" return "OK" with ex -> return $"ERROR: Compile failed: {ex.Message}" @@ -247,6 +260,59 @@ let startServer (config: ServerConfig) = File.WriteAllText(metaPath, $"""{{ "repoRoot":"{config.RepoRoot}", "pid":{Environment.ProcessId} }}""") + // ── Filewatcher: pre-warm cache on source changes ── + // Watch src/Compiler/ for .fs/.fsi changes. On modification, after a 5s quiet period, + // request a ParseAndCheckProject to warm the TransparentCompiler cache. + // By the time MSBuild calls us, the typecheck is already done. + let mutable lastFileChange = DateTimeOffset.MinValue + let watchPath = Path.Combine(config.RepoRoot, "src", "Compiler") + let fcsProjectPath = + Path.Combine(config.RepoRoot, "src", "Compiler", "FSharp.Compiler.Service.fsproj") + let prewarmThrottleMs = 5_000 + + let prewarmCache () = + async { + try + let! optionsResult = projectMgr.ResolveProjectOptions(fcsProjectPath) + match optionsResult with + | Ok options -> + let sw = System.Diagnostics.Stopwatch.StartNew() + let! _results = checker.ParseAndCheckProject(options) + eprintfn $"[fsharp-diag] Prewarm: typechecked in {sw.Elapsed.TotalMilliseconds:F0}ms" + | Error msg -> + eprintfn $"[fsharp-diag] Prewarm: options error: {msg}" + with ex -> + eprintfn $"[fsharp-diag] Prewarm: error: {ex.Message}" + } + + let schedulePrewarm () = + lastFileChange <- DateTimeOffset.UtcNow + let snapshot = lastFileChange + Async.Start( + async { + do! Async.Sleep(prewarmThrottleMs) + // Only fire if no newer change arrived during the throttle window + if lastFileChange = snapshot then + eprintfn $"[fsharp-diag] File change detected, pre-warming cache..." + do! prewarmCache () + }, cts.Token) + + let watcher = + if Directory.Exists(watchPath) then + let w = new FileSystemWatcher(watchPath, IncludeSubdirectories = true) + w.Filters.Add("*.fs") + w.Filters.Add("*.fsi") + w.NotifyFilter <- NotifyFilters.LastWrite ||| NotifyFilters.FileName + w.Changed.Add(fun _ -> schedulePrewarm ()) + w.Created.Add(fun _ -> schedulePrewarm ()) + w.Renamed.Add(fun _ -> schedulePrewarm ()) + w.EnableRaisingEvents <- true + eprintfn $"[fsharp-diag] Watching {watchPath} for source changes (5s throttle)" + Some w + else + eprintfn $"[fsharp-diag] Watch path not found: {watchPath}" + None + use listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified) listener.Bind(UnixDomainSocketEndPoint(socketPath)) listener.Listen(10) @@ -284,5 +350,6 @@ let startServer (config: ServerConfig) = try File.Delete(socketPath) with _ -> () try File.Delete(metaPath) with _ -> () + watcher |> Option.iter (fun w -> w.Dispose()) eprintfn "[fsharp-diag] Shut down." } diff --git a/eng/targets/FastBuildFromCache.targets b/eng/targets/FastBuildFromCache.targets index 5a2eebf0001..a22f353178c 100644 --- a/eng/targets/FastBuildFromCache.targets +++ b/eng/targets/FastBuildFromCache.targets @@ -8,20 +8,22 @@ ARCHITECTURE ============ - We do NOT redefine CoreCompile (that would break all other projects via - MSBuild's "last definition wins" semantics, since FastBuildFromCache is a - global property). Instead we use two cooperating mechanisms: + _FastBuildEmitFromCache is a REPLACEMENT for CoreCompile's Fsc invocation. + It mirrors CoreCompile's exact Inputs/Outputs so MSBuild treats it identically + for incremental build purposes: when outputs are fresh, both targets skip; + when inputs are stale, the cache target fires first, emits the DLL, and + CoreCompile's Fsc becomes a no-op via SkipCompilerExecution=true. - 1. A property `SkipCompilerExecution=true` — already supported by the Fsc - MSBuild task (Fsc.fs line 739). When set, Fsc returns exit code 0 without - invoking the compiler. CoreCompile runs normally (all dependencies, resource - gen, etc.) but produces no DLL. + We cannot redefine CoreCompile directly (MSBuild's "last definition wins" + would affect all projects, since FastBuildFromCache is a global property). + Instead: - 2. A target `_FastBuildEmitFromCache` with BeforeTargets="CoreCompile" — runs - our server call BEFORE CoreCompile. If the server succeeds, it writes the - DLL to @(IntermediateAssembly), and we set SkipCompilerExecution=true so - Fsc is a no-op. If the server fails, SkipCompilerExecution stays false and - Fsc runs normally as fallback. + 1. _FastBuildEmitFromCache (BeforeTargets="CoreCompile") — same Inputs/Outputs + as CoreCompile. Calls the diagnostics server to emit the DLL. On success, + sets SkipCompilerExecution=true. On failure, falls back to normal Fsc. + + 2. SkipCompilerExecution=true — built-in Fsc task property (Fsc.fs line 739). + Makes Fsc return exit code 0 without invoking the compiler. WHY THIS IS SAFE ================ @@ -43,10 +45,23 @@ UP-TO-DATE CHECK ================ - MSBuild's incremental build compares CoreCompile's Inputs vs Outputs. If the - DLL at @(IntermediateAssembly) is newer than all source files, CoreCompile - (and therefore our BeforeTargets target) is skipped entirely. This is correct: - the DLL is already fresh. + MSBuild's incremental build compares CoreCompile's Inputs vs Outputs. If ALL + outputs (DLL, PDB, ref assembly, XML doc, etc.) are newer than ALL inputs, + CoreCompile is skipped entirely. HOWEVER, BeforeTargets targets like + _FastBuildEmitFromCache still run even when CoreCompile itself is skipped — + MSBuild only skips the task body of targets with matching Inputs/Outputs. + + SOLUTION: _FastBuildEmitFromCache mirrors CoreCompile's exact Inputs/Outputs. + When all outputs are fresh, BOTH targets are skipped — zero server calls, + zero overhead. The build behaves exactly like vanilla: ~1s for a no-change + build. + + CRITICAL: When the cache path succeeds, Fsc is a no-op and does NOT produce + PDB, ref assembly, or XML doc files. If we only touch @(IntermediateAssembly), + the other outputs remain stale/missing, and MSBuild considers CoreCompile + out-of-date on the NEXT build — causing it to always re-run (~28s instead of + ~7s). The _FastBuildTouchOutputs target therefore touches ALL CoreCompile + outputs so the incremental check passes on subsequent builds. If a source file changes, CoreCompile re-runs, our target fires first, the server checks its cache. If cache is valid (server saw the same edit via LSP), @@ -78,15 +93,12 @@ + AND '$(AssemblyName)' == 'FSharp.Compiler.Service'"> <_FastBuildFromCacheActive>true <_FastBuildScript>$(RepoRoot).github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh @@ -103,6 +115,32 @@ Name="_FastBuildEmitFromCache" Condition="'$(_FastBuildFromCacheActive)' == 'true'" BeforeTargets="CoreCompile" + Inputs="$(MSBuildAllProjects); + @(CompileBefore); + @(Compile); + @(CompileAfter); + @(FscCompilerTools); + @(_CoreCompileResourceInputs); + @(ManifestNonResxWithNoCultureOnDisk); + $(ApplicationIcon); + $(AssemblyOriginatorKeyFile); + @(ReferencePathWithRefAssemblies); + @(CompiledLicenseFile); + @(EmbeddedDocumentation); + $(Win32Resource); + $(Win32Manifest); + @(CustomAdditionalCompileInputs); + $(VersionFile); + $(KeyOriginatorFile); + $(UseSource); + $(LoadSource); + $(SourceLink)" + Outputs="@(DocFileItem); + @(IntermediateAssembly); + @(IntermediateRefAssembly); + @(_DebugSymbolsIntermediatePath); + $(NonExistentFile); + @(CustomAdditionalCompileOutputs)" > - - + + + + + + ] TcImports | Some res -> res | None -> error (Error(FSComp.SR.buildCouldNotResolveAssembly assemblyName, m)) - member tcImports.NormalizeAssemblyRef(ctok, aref: ILAssemblyRef) = + member internal tcImports.NormalizeAssemblyRef(ctok, aref: ILAssemblyRef) = match tcImports.TryFindDllInfo(ctok, rangeStartup, aref.Name, lookupOnly = false) with | Some dllInfo -> match dllInfo.ILScopeRef with diff --git a/src/Compiler/Driver/CompilerImports.fsi b/src/Compiler/Driver/CompilerImports.fsi index de6334fd2e6..3aae4c30158 100644 --- a/src/Compiler/Driver/CompilerImports.fsi +++ b/src/Compiler/Driver/CompilerImports.fsi @@ -167,7 +167,7 @@ type TcImports = member TryFindDllInfo: CompilationThreadToken * range * string * lookupOnly: bool -> ImportedBinary option /// Normalize an assembly reference by resolving it through the imported assemblies table. - member NormalizeAssemblyRef: CompilationThreadToken * ILAssemblyRef -> ILAssemblyRef + member internal NormalizeAssemblyRef: CompilationThreadToken * ILAssemblyRef -> ILAssemblyRef member FindCcuFromAssemblyRef: CompilationThreadToken * range * ILAssemblyRef -> CcuResolutionResult diff --git a/src/Compiler/Service/FSharpCheckerResults.fs b/src/Compiler/Service/FSharpCheckerResults.fs index 5bb356d25d9..74dfd3b54b2 100644 --- a/src/Compiler/Service/FSharpCheckerResults.fs +++ b/src/Compiler/Service/FSharpCheckerResults.fs @@ -3835,11 +3835,11 @@ type FSharpCheckProjectResults FSharpAssemblyContents(tcGlobals, thisCcu, Some ccuSig, tcImports, mimpls) member internal _.CompilationData = - let tcGlobals, tcImports, thisCcu, _ccuSig, _, topAttribs, _, ilAssemRef, _, tcAssemblyExpr, _, _ = + let tcGlobals, tcImports, thisCcu, ccuSig, _, topAttribs, _, ilAssemRef, _, tcAssemblyExpr, _, _ = getDetails () let tcConfig = getTcConfig () - (tcConfig, tcGlobals, tcImports, thisCcu, topAttribs, ilAssemRef, tcAssemblyExpr) + (tcConfig, tcGlobals, tcImports, thisCcu, ccuSig, topAttribs, ilAssemRef, tcAssemblyExpr) member _.GetOptimizedAssemblyContents() = if not keepAssemblyContents then diff --git a/src/Compiler/Service/FSharpCheckerResults.fsi b/src/Compiler/Service/FSharpCheckerResults.fsi index 8e7a074ec6a..14296d0ecc0 100644 --- a/src/Compiler/Service/FSharpCheckerResults.fsi +++ b/src/Compiler/Service/FSharpCheckerResults.fsi @@ -535,7 +535,7 @@ type public FSharpCheckProjectResults = /// Get the internal compilation data needed for CompileFromCheckedProject. /// Requires keepAssemblyContents=true. member internal CompilationData: - TcConfig * TcGlobals * TcImports * CcuThunk * TopAttribs option * ILAssemblyRef * + TcConfig * TcGlobals * TcImports * CcuThunk * ModuleOrNamespaceType * TopAttribs option * ILAssemblyRef * CheckedImplFile list option /// Get the resolution of the ProjectOptions diff --git a/src/Compiler/Service/service.fs b/src/Compiler/Service/service.fs index 5ff606fef10..d6b5e25d9a7 100644 --- a/src/Compiler/Service/service.fs +++ b/src/Compiler/Service/service.fs @@ -638,9 +638,17 @@ type FSharpChecker /// Returns the output file path on success. member internal _.CompileFromCheckedProject(results: FSharpCheckProjectResults, outfile: string) = async { - let tcConfig, tcGlobals, tcImports, generatedCcu, topAttrsOpt, _ilAssemRef, typedImplFilesOpt = + let tcConfig, tcGlobals, tcImports, unfinalizedCcu, ccuSig, topAttrsOpt, _ilAssemRef, typedImplFilesOpt = results.CompilationData + ReportTime tcConfig "CompileFromCheckedProject: Setup" + + // The CCU from TransparentCompiler has unfinalized Contents (empty ModuleOrNamespaceType). + // Finalize it using ccuSig, matching what CheckClosedInputSetFinish does. + let ccuContents = + Construct.NewCcuContents ILScopeRef.Local range0 unfinalizedCcu.AssemblyName ccuSig + let generatedCcu = unfinalizedCcu.CloneWithFinalizedContents(ccuContents) + let topAttrs = match topAttrsOpt with | Some a -> a @@ -652,6 +660,33 @@ type FSharpChecker | None -> raise (InvalidOperationException "CompileFromCheckedProject: keepAssemblyContents must be true") + // Note: We do NOT filter files with diagnostics here. FSharpCheckProjectResults.Diagnostics + // may include warnings promoted to errors (e.g. FS1182 from --warnaserror+:1182) that + // are suppressed by #nowarn in the source. These files compiled successfully in the + // normal fsc pipeline and must be included here for IlxGen to resolve all types. + // If there are genuine type-check errors, IlxGen will fail and we fall back to fsc. + + // Deduplicate QualifiedNameOfFile values. TransparentCompiler processes files + // via dependency graph (potentially parallel), so the per-file DeduplicateParsedInputModuleName + // may not see all prior names. Re-deduplicate here to avoid startup code type collisions. + let typedImplFiles = + typedImplFiles + |> List.mapFold + (fun (seen: Map) (f: CheckedImplFile) -> + let name = f.QualifiedNameOfFile.Text + match seen.TryFind name with + | None -> + f, seen.Add(name, 1) + | Some count -> + let newCount = count + 1 + let newName = name + "___" + string newCount + let newQName = FSharp.Compiler.Syntax.QualifiedNameOfFile(FSharp.Compiler.Syntax.Ident(newName, f.QualifiedNameOfFile.Range)) + let (CheckedImplFile(_, sig', contents, hasEntry, isScript, anonRecs, namedDbgPts)) = f + CheckedImplFile(newQName, sig', contents, hasEntry, isScript, anonRecs, namedDbgPts), + seen.Add(name, newCount)) + Map.empty + |> fst + // Save and restore CCU attribs to prevent quadratic growth on repeated compile calls. let originalAttribs = generatedCcu.Contents.Attribs generatedCcu.Contents.SetAttribs(originalAttribs @ topAttrs.assemblyAttrs) @@ -663,18 +698,57 @@ type FSharpChecker let exportRemapping = MakeExportRemapping generatedCcu generatedCcu.Contents + ReportTime tcConfig "CompileFromCheckedProject: Encode Signature Data" let sigDataAttributes, sigDataResources = EncodeSignatureData(tcConfig, tcGlobals, exportRemapping, generatedCcu, outfile, false) - let optimizedImpls = - typedImplFiles - |> List.map (fun implFile -> - { ImplFile = implFile - OptimizeDuringCodeGen = fun _flag expr -> expr }) - |> CheckedAssemblyAfterOptimization - let tcVal = LightweightTcValForUsingInBuildMethodCall tcGlobals - + let importMap = tcImports.GetImportMap() + let optEnv0 = GetInitialOptimizationEnv(tcImports, tcGlobals) + + ReportTime tcConfig "CompileFromCheckedProject: Optimizations" + + // Dev-loop optimization: use minimal passes only (no extra loops, no detuple, + // no TLR, no cross-assembly opt). This DLL is for local testing, not shipping. + // OptimizeImplFile + LowerLocalMutables + LowerCalls are mandatory for correct IlxGen. + let optimizedImpls, optDataResources = + let minimalSettings = + { tcConfig.optSettings with + jitOptUser = Some false + localOptUser = Some false + crossAssemblyOptimizationUser = Some false + lambdaInlineThreshold = 0 + abstractBigTargets = false + reportingPhase = false + } + let impls = + typedImplFiles + |> List.mapFold + (fun (env, hidingInfo) implFile -> + let (env', file, _optInfo, hidingInfo'), optDuringCodeGen = + Optimizer.OptimizeImplFile( + minimalSettings, + generatedCcu, + tcGlobals, + tcVal, + importMap, + env, + false, + tcConfig.emitTailcalls, + hidingInfo, + implFile + ) + let file = LowerLocalMutables.TransformImplFile tcGlobals importMap file + let file = LowerCalls.LowerImplFile tcGlobals file + { ImplFile = file + OptimizeDuringCodeGen = optDuringCodeGen }, + (env', hidingInfo')) + (optEnv0, SignatureHidingInfo.Empty) + |> fst + |> CheckedAssemblyAfterOptimization + impls, [] + + ReportTime tcConfig "CompileFromCheckedProject: TAST -> IL" let ilxGenerator = CreateIlxAssemblyGenerator(tcConfig, tcImports, tcGlobals, tcVal, generatedCcu) @@ -700,28 +774,43 @@ type FSharpChecker let ctok = CompilationThreadToken() + // Extract AssemblyVersionAttribute from typed assembly attributes, matching fsc's logic. + let assemVerFromAttrib = + match AttributeHelpers.TryFindStringAttribute tcGlobals "System.Reflection.AssemblyVersionAttribute" topAttrs.assemblyAttrs with + | Some versionString -> + try Some(parseILVersion versionString) + with _ -> None + | _ -> + match tcConfig.version with + | VersionNone -> Some(ILVersionInfo(0us, 0us, 0us, 0us)) + | _ -> Some(tcConfig.version.GetVersionInfo tcConfig.implicitIncludeDir) + let ilxMainModule = - MainModuleBuilder.CreateMainModule( - ctok, - tcConfig, - tcGlobals, - tcImports, - None, - generatedCcu.AssemblyName, - outfile, - topAttrs, - sigDataAttributes, - sigDataResources, - [], - codegenResults, - None, - metadataVersion, - secDecls - ) + let m = + MainModuleBuilder.CreateMainModule( + ctok, + tcConfig, + tcGlobals, + tcImports, + None, + generatedCcu.AssemblyName, + outfile, + topAttrs, + sigDataAttributes, + sigDataResources, + optDataResources, + codegenResults, + assemVerFromAttrib, + metadataVersion, + secDecls + ) + // Strip native resources — default.win32manifest may not exist on all platforms. + { m with NativeResources = [] } let normalizeAssemblyRefs (aref: ILAssemblyRef) = tcImports.NormalizeAssemblyRef(ctok, aref) + ReportTime tcConfig "CompileFromCheckedProject: Write .NET Binary" WriteILBinaryFile( { ilg = tcGlobals.ilg @@ -736,7 +825,7 @@ type FSharpChecker allGivenSources = [] sourceLink = "" checksumAlgorithm = tcConfig.checksumAlgorithm - signer = None + signer = GetStrongNameSigner(ValidateKeySigningAttributes(tcConfig, tcGlobals, topAttrs)) dumpDebugInfo = false referenceAssemblyOnly = false referenceAssemblyAttribOpt = None @@ -746,6 +835,7 @@ type FSharpChecker ilxMainModule, normalizeAssemblyRefs ) + ReportTime tcConfig "Exiting" return outfile } From 20f41e3a3b4801f03093bcdeb5a9d47291f3a4a2 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Wed, 11 Feb 2026 22:51:11 +0100 Subject: [PATCH 17/21] skill reworded --- .../fsharp-diagnostics/PLAN_FOR_WINDOWS.md | 100 ++++++++++++++++++ .github/skills/fsharp-diagnostics/SKILL.md | 44 +++----- 2 files changed, 116 insertions(+), 28 deletions(-) create mode 100644 .github/skills/fsharp-diagnostics/PLAN_FOR_WINDOWS.md diff --git a/.github/skills/fsharp-diagnostics/PLAN_FOR_WINDOWS.md b/.github/skills/fsharp-diagnostics/PLAN_FOR_WINDOWS.md new file mode 100644 index 00000000000..123c9d65d19 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/PLAN_FOR_WINDOWS.md @@ -0,0 +1,100 @@ +# Windows Support Plan for FastBuildFromCache + +## Strategy + +Use **Unix Domain Sockets (UDS) on all platforms**, including Windows. +Windows 10 1803+ supports `AF_UNIX`. .NET's `UnixDomainSocketEndPoint` works cross-platform since .NET Core 3.0+. +This keeps the server transport code unchanged — the work is a new PowerShell client script and minor path fixes. + +**Why not Named Pipes?** Would require a transport abstraction in the server (different accept-loop lifecycle for `NamedPipeServerStream` vs `Socket`), doubling transport code for no benefit when UDS works. + +**Why not a .NET client tool?** Chicken-and-egg: the client must exist before FCS builds, but would itself need building first. Also adds ~150ms JIT startup per invocation. + +**Prerequisite:** `pwsh` (PowerShell 7+). Windows PowerShell 5.1 lacks `UnixDomainSocketEndPoint`. The MSBuild targets use `ContinueOnError="true"`, so missing `pwsh` gracefully falls back to normal `fsc`. + +--- + +## Changes + +### 1. `eng/targets/FastBuildFromCache.targets` + +Replace hardcoded `bash` with OS-conditional properties and a single ``: + +```xml +<_FastBuildScript Condition="'$(OS)'!='Windows_NT'">...get-fsharp-errors.sh +<_FastBuildScript Condition="'$(OS)'=='Windows_NT'">...get-fsharp-errors.ps1 +<_FastBuildInterpreter Condition="'$(OS)'!='Windows_NT'">bash +<_FastBuildInterpreter Condition="'$(OS)'=='Windows_NT'">pwsh -NoProfile -File +``` + +Single Exec: `Command="$(_FastBuildInterpreter) "$(_FastBuildScript)" ..."` + +### 2. NEW: `scripts/get-fsharp-errors.ps1` + +PowerShell Core port of `get-fsharp-errors.sh` (~100-120 lines). Key translations: + +| Bash | PowerShell | +|------|------------| +| `shasum -a 256` | `[System.Security.Cryptography.SHA256]::HashData()` | +| `nc -U "$sock"` | `[System.Net.Sockets.Socket]` + `[UnixDomainSocketEndPoint]` + `NetworkStream` + `StreamReader/Writer` | +| `nohup dotnet run ... &` | `Start-Process dotnet -ArgumentList ... -NoNewWindow` | +| `[ -S "$sock" ]` | `Test-Path $sock` | +| `set -euo pipefail` | `$ErrorActionPreference = 'Stop'; Set-StrictMode -Version Latest` | +| `$HOME/.fsharp-diag` | `Join-Path $env:USERPROFILE '.fsharp-diag'` | + +Same JSON protocol, same command-line interface (`--compile`, `--parse-only`, etc.). + +### 3. `server/Server.fs` + +Two one-line fixes: + +- **`File.SetUnixFileMode`** (throws `PlatformNotSupportedException` on Windows): + ```fsharp + if not (OperatingSystem.IsWindows()) then File.SetUnixFileMode(socketPath, ...) + ``` + +- **`TrimEnd('/')`** (doesn't strip `\` on Windows paths): + ```fsharp + config.RepoRoot.TrimEnd('/', '\\') + "/" + ``` + +### 4. `server/ProjectRouting.fs` + +- `TrimEnd('/')` → `TrimEnd('/', '\\')` +- `StringComparison.Ordinal` → `StringComparison.OrdinalIgnoreCase` for path prefix checks +- Normalize relative path: `.Replace('\\', '/')` before pattern matching against `"tests/"`, `"src/"` etc. + +### 5. `server/DiagnosticsFormatter.fs` + +- `TrimEnd('/')` → `TrimEnd('/', '\\')` +- `StringComparison.OrdinalIgnoreCase` for `path.StartsWith(root)` + +--- + +## Critical: Path Normalization Before Hashing + +The socket path is derived from `SHA256(repoRoot)`. Client and server **must hash the exact same string** or they'll look for different sockets. + +Problem: `git rev-parse --show-toplevel` returns `C:/Users/foo/fsharp` on Windows (forward slashes), but .NET's `Directory.GetCurrentDirectory()` returns `C:\Users\foo\fsharp` (backslashes). + +**Rule:** Before hashing, normalize to: forward slashes, no trailing separator. +Apply this in both the PS1 script and `deriveSocketPath` in Server.fs. + +--- + +## What Does NOT Need Changing + +- **Socket transport in Server.fs** — `Socket(AddressFamily.Unix)` + `UnixDomainSocketEndPoint` works on Windows +- **FileSystemWatcher** — cross-platform in .NET +- **FSharpDiagServer.fsproj** — `net10.0` SDK project, fully cross-platform +- **Program.fs** — no OS-specific code +- **All product code** (`service.fs`, `FSharpCheckerResults.fs`, `CompilerImports.fs`, `fsc.fs`) — already cross-platform + +## Testing Checklist + +- [ ] `pwsh` can connect to server via UDS on Windows +- [ ] Server spawns correctly via `Start-Process` from PS1 +- [ ] Socket path matches between PS1 client and server (hash normalization) +- [ ] `dotnet test ... /p:FastBuildFromCache=true` works end-to-end on Windows +- [ ] Graceful fallback when `pwsh` is not installed +- [ ] No-change build is a no-op (MSBuild incremental skip works on Windows) diff --git a/.github/skills/fsharp-diagnostics/SKILL.md b/.github/skills/fsharp-diagnostics/SKILL.md index ae1295fbf18..92838f8bdd0 100644 --- a/.github/skills/fsharp-diagnostics/SKILL.md +++ b/.github/skills/fsharp-diagnostics/SKILL.md @@ -13,44 +13,32 @@ description: "After modifying any F# file, use this to get quick parse errors an GetErrors() { "$(git rev-parse --show-toplevel)/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh" "$@"; } ``` -## Parse first, typecheck second +## Rules -```bash -GetErrors --parse-only src/Compiler/Checking/CheckBasics.fs -``` -If errors → fix syntax. Do NOT typecheck until parse is clean. -```bash -GetErrors src/Compiler/Checking/CheckBasics.fs -``` +1. **After every edit** to a `src/Compiler/*.fs` file → typecheck it before proceeding. This catches errors in ~2s vs ~35s for a full build. Do NOT attempt `dotnet build` or `dotnet test` until the file typechecks clean. +2. **Use `--find-refs` instead of grep** for finding usages of a symbol (function, type, member, field). Returns semantically resolved references — no false positives from comments, strings, or similarly-named symbols. +3. **Use `--type-hints` to read code blocks** — F# infers most types, so bindings like `env`, `state`, `x` are opaque without it. + - ⚠️ Output has `// (name: Type)` annotations. These are **read-only overlays**. When editing, use `view` to get the real unannotated source. +4. **Parse first, typecheck second** — fix `--parse-only` errors before running a full typecheck. -## Find references for a single symbol (line 1-based, col 0-based) +## Commands -Before renaming or to understand call sites: ```bash -GetErrors --find-refs src/Compiler/Checking/CheckBasics.fs 30 5 +GetErrors --parse-only src/Compiler/path/File.fs # parse errors only +GetErrors src/Compiler/path/File.fs # full typecheck +GetErrors --find-refs src/Compiler/path/File.fs 30 5 # references (line 1-based, col 0-based) +GetErrors --type-hints src/Compiler/path/File.fs 50 60 # annotated code (line range, 1-based) +GetErrors --check-project # typecheck entire project +GetErrors --ping # server alive? +GetErrors --shutdown # stop server ``` -## Type hints for a range selection (begin and end line numbers, 1-based) +## Cached test runs -To see inferred types as inline `// (name: Type)` comments: -```bash -GetErrors --type-hints src/Compiler/TypedTree/TypedTreeOps.fs 1028 1032 -``` +No separate `dotnet build` of FSharp.Compiler.Service needed — `dotnet test` builds all dependencies automatically. -## Running tests faster - -After checking errors are clean, run tests with cached compilation (skips full recompile of FSharp.Compiler.Service and FSharp.Compiler.ComponentTests): ```bash dotnet test tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj -c Release /p:FastBuildFromCache=true ``` -All other projects build normally. Falls back to normal build if server is unavailable. - -## Other - -```bash -GetErrors --check-project # typecheck entire project -GetErrors --ping -GetErrors --shutdown -``` First call starts server (~70s cold start, set initial_wait=600). Auto-shuts down after 4h idle. ~3 GB RAM. From a7d18152762bb74bfbef9033e3abea22c139edd8 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Fri, 13 Feb 2026 15:23:11 +0100 Subject: [PATCH 18/21] Fix CI: update PerfTests.fs tuple destructuring + format --- src/Compiler/Service/FSharpCheckerResults.fsi | 8 ++- src/Compiler/Service/service.fs | 60 +++++++++++-------- src/Compiler/Service/service.fsi | 3 +- .../PerfTests.fs | 2 +- 4 files changed, 43 insertions(+), 30 deletions(-) diff --git a/src/Compiler/Service/FSharpCheckerResults.fsi b/src/Compiler/Service/FSharpCheckerResults.fsi index 14296d0ecc0..05266287790 100644 --- a/src/Compiler/Service/FSharpCheckerResults.fsi +++ b/src/Compiler/Service/FSharpCheckerResults.fsi @@ -535,7 +535,13 @@ type public FSharpCheckProjectResults = /// Get the internal compilation data needed for CompileFromCheckedProject. /// Requires keepAssemblyContents=true. member internal CompilationData: - TcConfig * TcGlobals * TcImports * CcuThunk * ModuleOrNamespaceType * TopAttribs option * ILAssemblyRef * + TcConfig * + TcGlobals * + TcImports * + CcuThunk * + ModuleOrNamespaceType * + TopAttribs option * + ILAssemblyRef * CheckedImplFile list option /// Get the resolution of the ProjectOptions diff --git a/src/Compiler/Service/service.fs b/src/Compiler/Service/service.fs index d6b5e25d9a7..f2aaaf5845f 100644 --- a/src/Compiler/Service/service.fs +++ b/src/Compiler/Service/service.fs @@ -645,8 +645,7 @@ type FSharpChecker // The CCU from TransparentCompiler has unfinalized Contents (empty ModuleOrNamespaceType). // Finalize it using ccuSig, matching what CheckClosedInputSetFinish does. - let ccuContents = - Construct.NewCcuContents ILScopeRef.Local range0 unfinalizedCcu.AssemblyName ccuSig + let ccuContents = Construct.NewCcuContents ILScopeRef.Local range0 unfinalizedCcu.AssemblyName ccuSig let generatedCcu = unfinalizedCcu.CloneWithFinalizedContents(ccuContents) let topAttrs = @@ -657,8 +656,7 @@ type FSharpChecker let typedImplFiles = match typedImplFilesOpt with | Some files -> files - | None -> - raise (InvalidOperationException "CompileFromCheckedProject: keepAssemblyContents must be true") + | None -> raise (InvalidOperationException "CompileFromCheckedProject: keepAssemblyContents must be true") // Note: We do NOT filter files with diagnostics here. FSharpCheckProjectResults.Diagnostics // may include warnings promoted to errors (e.g. FS1182 from --warnaserror+:1182) that @@ -674,16 +672,18 @@ type FSharpChecker |> List.mapFold (fun (seen: Map) (f: CheckedImplFile) -> let name = f.QualifiedNameOfFile.Text + match seen.TryFind name with - | None -> - f, seen.Add(name, 1) + | None -> f, seen.Add(name, 1) | Some count -> let newCount = count + 1 let newName = name + "___" + string newCount - let newQName = FSharp.Compiler.Syntax.QualifiedNameOfFile(FSharp.Compiler.Syntax.Ident(newName, f.QualifiedNameOfFile.Range)) + + let newQName = + FSharp.Compiler.Syntax.QualifiedNameOfFile(FSharp.Compiler.Syntax.Ident(newName, f.QualifiedNameOfFile.Range)) + let (CheckedImplFile(_, sig', contents, hasEntry, isScript, anonRecs, namedDbgPts)) = f - CheckedImplFile(newQName, sig', contents, hasEntry, isScript, anonRecs, namedDbgPts), - seen.Add(name, newCount)) + CheckedImplFile(newQName, sig', contents, hasEntry, isScript, anonRecs, namedDbgPts), seen.Add(name, newCount)) Map.empty |> fst @@ -694,11 +694,13 @@ type FSharpChecker use _restoreAttribs = { new System.IDisposable with member _.Dispose() = - generatedCcu.Contents.SetAttribs(originalAttribs) } + generatedCcu.Contents.SetAttribs(originalAttribs) + } let exportRemapping = MakeExportRemapping generatedCcu generatedCcu.Contents ReportTime tcConfig "CompileFromCheckedProject: Encode Signature Data" + let sigDataAttributes, sigDataResources = EncodeSignatureData(tcConfig, tcGlobals, exportRemapping, generatedCcu, outfile, false) @@ -721,6 +723,7 @@ type FSharpChecker abstractBigTargets = false reportingPhase = false } + let impls = typedImplFiles |> List.mapFold @@ -738,33 +741,34 @@ type FSharpChecker hidingInfo, implFile ) + let file = LowerLocalMutables.TransformImplFile tcGlobals importMap file let file = LowerCalls.LowerImplFile tcGlobals file - { ImplFile = file - OptimizeDuringCodeGen = optDuringCodeGen }, + + { + ImplFile = file + OptimizeDuringCodeGen = optDuringCodeGen + }, (env', hidingInfo')) (optEnv0, SignatureHidingInfo.Empty) |> fst |> CheckedAssemblyAfterOptimization + impls, [] ReportTime tcConfig "CompileFromCheckedProject: TAST -> IL" - let ilxGenerator = - CreateIlxAssemblyGenerator(tcConfig, tcImports, tcGlobals, tcVal, generatedCcu) + let ilxGenerator = CreateIlxAssemblyGenerator(tcConfig, tcImports, tcGlobals, tcVal, generatedCcu) let codegenResults = - GenerateIlxCode( - IlWriteBackend, - false, - tcConfig, - topAttrs, - optimizedImpls, - generatedCcu.AssemblyName, - ilxGenerator - ) + GenerateIlxCode(IlWriteBackend, false, tcConfig, topAttrs, optimizedImpls, generatedCcu.AssemblyName, ilxGenerator) let topAssemblyAttrs = codegenResults.topAssemblyAttrs - let topAttrs = { topAttrs with assemblyAttrs = topAssemblyAttrs } + + let topAttrs = + { topAttrs with + assemblyAttrs = topAssemblyAttrs + } + let secDecls = mkILSecurityDecls codegenResults.permissionSets let metadataVersion = @@ -778,8 +782,10 @@ type FSharpChecker let assemVerFromAttrib = match AttributeHelpers.TryFindStringAttribute tcGlobals "System.Reflection.AssemblyVersionAttribute" topAttrs.assemblyAttrs with | Some versionString -> - try Some(parseILVersion versionString) - with _ -> None + try + Some(parseILVersion versionString) + with _ -> + None | _ -> match tcConfig.version with | VersionNone -> Some(ILVersionInfo(0us, 0us, 0us, 0us)) @@ -811,6 +817,7 @@ type FSharpChecker tcImports.NormalizeAssemblyRef(ctok, aref) ReportTime tcConfig "CompileFromCheckedProject: Write .NET Binary" + WriteILBinaryFile( { ilg = tcGlobals.ilg @@ -835,6 +842,7 @@ type FSharpChecker ilxMainModule, normalizeAssemblyRefs ) + ReportTime tcConfig "Exiting" return outfile diff --git a/src/Compiler/Service/service.fsi b/src/Compiler/Service/service.fsi index 0b660d85878..31aef3d1cdf 100644 --- a/src/Compiler/Service/service.fsi +++ b/src/Compiler/Service/service.fsi @@ -511,8 +511,7 @@ type public FSharpChecker = /// Compile a DLL from cached typecheck results, skipping parse/typecheck/optimization. /// For dev-loop use only. Requires keepAssemblyContents=true. /// Returns the output file path on success. - member internal CompileFromCheckedProject: - results: FSharpCheckProjectResults * outfile: string -> Async + member internal CompileFromCheckedProject: results: FSharpCheckProjectResults * outfile: string -> Async /// Tokenize a single line, returning token information and a tokenization state represented by an integer member TokenizeLine: line: string * state: FSharpTokenizerLexState -> FSharpTokenInfo[] * FSharpTokenizerLexState diff --git a/tests/FSharp.Compiler.Service.Tests/PerfTests.fs b/tests/FSharp.Compiler.Service.Tests/PerfTests.fs index 3a614462f2c..be3eb8a5b94 100644 --- a/tests/FSharp.Compiler.Service.Tests/PerfTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/PerfTests.fs @@ -112,7 +112,7 @@ let add x y = x + y Assert.False(results.HasCriticalErrors, "Project should have no critical errors") - let _tcConfig, _tcGlobals, _tcImports, generatedCcu, _topAttrsOpt, _ilAssemRef, _typedImplFilesOpt = + let _tcConfig, _tcGlobals, _tcImports, generatedCcu, _ccuSig, _topAttrsOpt, _ilAssemRef, _typedImplFilesOpt = results.CompilationData let attribCountBefore = generatedCcu.Contents.Attribs.Length From 516b5ee3ad349eb42c2e2e757f79b5622400c887 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Fri, 13 Feb 2026 16:09:17 +0100 Subject: [PATCH 19/21] Add release notes entry for PR #19267 --- docs/release-notes/.FSharp.Compiler.Service/10.0.300.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/.FSharp.Compiler.Service/10.0.300.md b/docs/release-notes/.FSharp.Compiler.Service/10.0.300.md index 56edf5d2cda..3b830fbd666 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/10.0.300.md +++ b/docs/release-notes/.FSharp.Compiler.Service/10.0.300.md @@ -5,6 +5,8 @@ ### Added +* Added internal `CompileFromCheckedProject` API to `FSharpChecker` for emitting DLLs directly from typecheck cache, enabling fast dev-loop builds. ([PR #19267](https://github.com/dotnet/fsharp/pull/19267)) + ### Changed * Centralized product TFM (Target Framework Moniker) into MSBuild props file `eng/TargetFrameworks.props`. Changing the target framework now only requires editing one file, and it integrates with MSBuild's `--getProperty` for scripts. From ee69d0b9dfb9aca1580f2b05d23019ce90d1ea59 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Fri, 13 Feb 2026 16:09:21 +0100 Subject: [PATCH 20/21] Add CI fixup sprint for release notes --- .../ralph/sprints/99_CI_Fixup_ReleaseNotes.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .tools/ralph/sprints/99_CI_Fixup_ReleaseNotes.md diff --git a/.tools/ralph/sprints/99_CI_Fixup_ReleaseNotes.md b/.tools/ralph/sprints/99_CI_Fixup_ReleaseNotes.md new file mode 100644 index 00000000000..22728751c66 --- /dev/null +++ b/.tools/ralph/sprints/99_CI_Fixup_ReleaseNotes.md @@ -0,0 +1,47 @@ +--- +--- +# Sprint: Fix CI - Add Missing Release Notes Entry + +## Context + +PR #19267 (branch `feature/langserver-skill`) previously failed ALL 25 CI jobs due to a PerfTests.fs tuple mismatch and formatting issues. Those were fixed in commit `a7d181527`. The new CI runs (AzDo builds 1293493/1293495) show **0 build errors and 0 test failures** — all AzDo jobs pass. + +However, the GitHub Actions `check_release_notes` check still FAILS because the PR modifies files under `src/Compiler/` but does not include a corresponding release notes entry in `docs/release-notes/.FSharp.Compiler.Service/10.0.300.md`. + +The check requires: +1. The release notes file is modified in the PR diff +2. The file contains the PR URL: `https://github.com/dotnet/fsharp/pull/19267` + +## Description + +### Files to Modify +- `docs/release-notes/.FSharp.Compiler.Service/10.0.300.md` — add a release notes entry for PR #19267 + +### Implementation Steps + +1. Open `docs/release-notes/.FSharp.Compiler.Service/10.0.300.md` +2. Under the `### Added` section, add the following bullet: + +``` +* Added internal `CompileFromCheckedProject` API to `FSharpChecker` for emitting DLLs directly from typecheck cache, enabling fast dev-loop builds. ([PR #19267](https://github.com/dotnet/fsharp/pull/19267)) +``` + +3. Commit with message: `Add release notes entry for PR #19267` +4. Push to `origin/feature/langserver-skill` + +### What to Avoid +- Do NOT modify any other files +- Do NOT change the existing release notes entries +- The entry MUST contain the exact URL `https://github.com/dotnet/fsharp/pull/19267` (the check greps for it) + +### Verification +After pushing, the `check_release_notes` GitHub Action should pass. You can verify locally by confirming the file contains the PR URL: +```bash +grep "https://github.com/dotnet/fsharp/pull/19267" docs/release-notes/.FSharp.Compiler.Service/10.0.300.md +``` + +## Definition of Done +- `docs/release-notes/.FSharp.Compiler.Service/10.0.300.md` contains a bullet with `https://github.com/dotnet/fsharp/pull/19267` +- The entry is under `### Added` section (since this adds a new internal API) +- Changes committed and pushed +- `check_release_notes` CI check passes on next run From de00b3510de211a49431e9455cb427c4f9e32351 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Fri, 13 Feb 2026 16:44:58 +0100 Subject: [PATCH 21/21] Fix verifier issues: remove tracked sprint file, add missing IntermediateOutputPath field - git rm --cached 99_CI_Fixup_ReleaseNotes.md (NO-LEFTOVERS) - Add IntermediateOutputPath to DtbResult constructions in DesignTimeBuildTests.fs (TEST-CODE-QUALITY) --- .../tests/DesignTimeBuildTests.fs | 4 +- .../ralph/sprints/99_CI_Fixup_ReleaseNotes.md | 47 ------------------- 2 files changed, 2 insertions(+), 49 deletions(-) delete mode 100644 .tools/ralph/sprints/99_CI_Fixup_ReleaseNotes.md diff --git a/.github/skills/fsharp-diagnostics/tests/DesignTimeBuildTests.fs b/.github/skills/fsharp-diagnostics/tests/DesignTimeBuildTests.fs index cc992d64691..5c13a1132d6 100644 --- a/.github/skills/fsharp-diagnostics/tests/DesignTimeBuildTests.fs +++ b/.github/skills/fsharp-diagnostics/tests/DesignTimeBuildTests.fs @@ -10,7 +10,7 @@ let ``defaultConfig has expected values`` () = [] let ``DtbResult can hold compiler args`` () = - let result = { CompilerArgs = [| "--debug"; "src/A.fs" |] } + let result = { CompilerArgs = [| "--debug"; "src/A.fs" |]; IntermediateOutputPath = "obj/Release/" } Assert.Equal(2, result.CompilerArgs.Length) Assert.Equal("--debug", result.CompilerArgs.[0]) @@ -24,5 +24,5 @@ let ``DtbConfig construction preserves values`` (tfm: string, cfg: string) = [] let ``DtbResult with empty CompilerArgs`` () = - let result = { CompilerArgs = [||] } + let result = { CompilerArgs = [||]; IntermediateOutputPath = "" } Assert.Empty(result.CompilerArgs) diff --git a/.tools/ralph/sprints/99_CI_Fixup_ReleaseNotes.md b/.tools/ralph/sprints/99_CI_Fixup_ReleaseNotes.md deleted file mode 100644 index 22728751c66..00000000000 --- a/.tools/ralph/sprints/99_CI_Fixup_ReleaseNotes.md +++ /dev/null @@ -1,47 +0,0 @@ ---- ---- -# Sprint: Fix CI - Add Missing Release Notes Entry - -## Context - -PR #19267 (branch `feature/langserver-skill`) previously failed ALL 25 CI jobs due to a PerfTests.fs tuple mismatch and formatting issues. Those were fixed in commit `a7d181527`. The new CI runs (AzDo builds 1293493/1293495) show **0 build errors and 0 test failures** — all AzDo jobs pass. - -However, the GitHub Actions `check_release_notes` check still FAILS because the PR modifies files under `src/Compiler/` but does not include a corresponding release notes entry in `docs/release-notes/.FSharp.Compiler.Service/10.0.300.md`. - -The check requires: -1. The release notes file is modified in the PR diff -2. The file contains the PR URL: `https://github.com/dotnet/fsharp/pull/19267` - -## Description - -### Files to Modify -- `docs/release-notes/.FSharp.Compiler.Service/10.0.300.md` — add a release notes entry for PR #19267 - -### Implementation Steps - -1. Open `docs/release-notes/.FSharp.Compiler.Service/10.0.300.md` -2. Under the `### Added` section, add the following bullet: - -``` -* Added internal `CompileFromCheckedProject` API to `FSharpChecker` for emitting DLLs directly from typecheck cache, enabling fast dev-loop builds. ([PR #19267](https://github.com/dotnet/fsharp/pull/19267)) -``` - -3. Commit with message: `Add release notes entry for PR #19267` -4. Push to `origin/feature/langserver-skill` - -### What to Avoid -- Do NOT modify any other files -- Do NOT change the existing release notes entries -- The entry MUST contain the exact URL `https://github.com/dotnet/fsharp/pull/19267` (the check greps for it) - -### Verification -After pushing, the `check_release_notes` GitHub Action should pass. You can verify locally by confirming the file contains the PR URL: -```bash -grep "https://github.com/dotnet/fsharp/pull/19267" docs/release-notes/.FSharp.Compiler.Service/10.0.300.md -``` - -## Definition of Done -- `docs/release-notes/.FSharp.Compiler.Service/10.0.300.md` contains a bullet with `https://github.com/dotnet/fsharp/pull/19267` -- The entry is under `### Added` section (since this adds a new internal API) -- Changes committed and pushed -- `check_release_notes` CI check passes on next run