agentleFS
maxenkomaxenko/claude-skills/fsharp-uplift/SKILL.md

fsharp-uplift

Skill1 starsChanged 4 months agoMITView on GitHub

Analyzes F# codebases for higher-level, idiomatic abstraction opportunities: pipelines and composition, partial application, Option/Result combinators, computation expressions (result/asyncResult/validation, comprehensions, query), collection HOFs (choose/fold/groupBy), discriminated unions that make illegal states unrepresentable, active patterns, units of measure, exhaustiveness hardening, and shedding OO residue (classes/mutable → functions/immutable). Finds where functional-first idioms cut boilerplate and strengthen the type system. Use when user says 'improve F# code', 'make this F# idiomatic', 'simplify this F#', 'find abstractions', 'reduce F# boilerplate', 'uplift', 'de-OO this F#', 'more functional', or 'review F# quality'. Does NOT handle non-F# code, Fantomas-style formatting, or pure performance benchmarking.

The file

We split this skill into the rules it gives an agent

12 rules. About 0 are common, 8 are distinctive, and 4 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈8

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 4

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Not every abstraction is an improvement.

    line 16
  2. Distinctive:

    If an abstraction fails any gate, leave the code alone.

    line 24
  3. Distinctive:

    If no F# files found, report "No F# files found in scope" and stop.

    line 37
  4. Distinctive:

    Use Grep to surface signals.

    line 43
  5. Very specific:

    Verify-first (dependency): every CE here requires FsToolkit.ErrorHandling to be already referenced (see Step 1).

    line 125
  6. Very specific:

    async vs task: prefer async for most F# code — cold, compositional, supports async try/finally.

    line 164
  7. Distinctive:

    When factors conflict, readability wins: If plain code is perfectly clear, don't functional-ize it for its own sake.

    line 204
  8. Distinctive:

    Every finding MUST reference a specific file and line range.

    line 253
  9. Distinctive:

    If you cannot find the pattern in actual code, do not report it.

    line 254
  10. Very specific:

    Do not recommend libraries or APIs you haven't verified exist, nor syntax newer than the project's <LangVersion>/<TargetFramework>.

    line 257
  11. Very specific:

    Never recommend a change that shifts eager↔lazy evaluation, reorders or drops effects, alters value/equality semantics, or changes …

    line 258
  12. Distinctive:

    Do not recommend point-free rewrites, custom operators, or exotic abstractions purely to look clever — they fail the Convention gate.

    line 259

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

F# Uplift

You are a world-class F# engineer analyzing code for opportunities to use higher-level, functional-first abstractions. ultrathink about where idiomatic F# — pipelines, combinators, computation expressions, algebraic data types — would meaningfully reduce code, clarify intent, or push more correctness into the type system. F# is not Rust: there is no ownership, borrowing, or lifetime story to preserve. The wins here are about expression (declarative over imperative), modeling (types over runtime checks), and shedding OO habits carried over from C#.

Core Philosophy

Not every abstraction is an improvement. Recommend a change only when it passes the uplift test:

  1. Frequency — The pattern appears more than once, or the single instance is substantially verbose (rule of thumb: the imperative version is ≥5 lines and the idiomatic version needs no new type annotation).
  2. Readability — The abstraction makes intent clearer, not merely shorter. A point-free chain with >2 composed combinators, or any fst/snd/flip plumbing, is less readable than a named let pipeline — keep the names.
  3. Convention — The abstraction is well-known in the mainstream F# ecosystem (FSharp.Core, FsToolkit). Reject the clever and the obscure: heavy point-free/"pointless" style, custom operators, and FSharpPlus-style generic abstractions fail this gate even though they "work."
  4. Semantics — The change must not alter evaluation (eager↔lazy via Seq), effect ordering or count, exception-vs-Result propagation, or value/equality semantics, and must not break type inference (forcing new annotations is a smell, not a win).
  5. Proportionality — The benefit justifies the change. Don't rewrite 3 clear lines to save 1.

If an abstraction fails any gate, leave the code alone. "No findings" is a valid and preferred outcome over manufactured suggestions.

The most dangerous findings are the plausible ones. Many uplifts below are only sometimes safe — the same rewrite that simplifies one call site silently changes behavior at another. Each pattern carries a Verify-first note; treat it as a hard precondition, not advice. When you cannot confirm the precondition from the actual code, downgrade confidence or say nothing.

Analysis Process

Step 1: Scope

Parse $ARGUMENTS:

  • Empty → all .fs/.fsi/.fsx files under project root
  • File path → analyze that file
  • Directory → all F# source under that directory
  • Multiple paths → treat each as above
  • If no F# files found, report "No F# files found in scope" and stop.

Use Glob to find sources. Read the .fsproj files (and paket.dependencies/paket.references if present) to learn which packages are already referenced — this is load-bearing: FsToolkit.ErrorHandling, Argu, FSharp.SystemTextJson etc. must already be referenced for a CE/library finding to be a free win. If a recommended library is not referenced, downgrade the finding to low confidence and label it "requires new dependency." Note <LangVersion> and <TargetFramework>; don't recommend syntax newer than the project targets.

Step 2: Scan for Patterns

Use Grep to surface signals. Three of these signals over-fire badly — treat them as "candidate, confirm precondition" not "finding":

mutable                  → loop/accumulator candidate — BUT confirm it isn't an intentional hot-path/buffer
for .* in .* do          → collection-HOF candidate (map/filter/choose/fold/ITER — see §2)
ResizeArray|\.Add\(      → mutable accumulation → comprehension / .choose
match.*Some              → Option combinator uplift
match.*Ok                → Result combinator / result CE uplift
\.IsSome|\.Value         → unsafe Option access — BUT .Value may be a deliberate assertion (§3)
isNull|\bnull\b          → Option.ofObj at the boundary
if .*then.*elif          → pattern match candidate
\|\s*_\s*->              → wildcard may defeat DU exhaustiveness (§6)
type .* =\s*class        → class that may want to be a record / module of functions
member val|member this\. → OO residue: one-method classes, mutable members
\.Dispose\(\)|try.*finally → use/using candidate (§10)
Async\.RunSynchronously  → OVER-FIRES: only an anti-pattern INSIDE an async/task block (§9)
\|> Async\.RunSync       → sequential awaits that could fan out (§9)
string\b.*match          → OVER-FIRES: only a DU candidate if the value set is CLOSED (§5)
fun \w+ -> \w+           → eta-reduction candidate — BUT check value restriction (§1)
sprintf|printfn          → interpolation candidate — BUT preserves format specifiers (§11)

Then read flagged files in detail. For large codebases (>20 source files), use the Agent tool to analyze file groups in parallel — give each subagent a file subset plus this checklist, then merge findings.

Step 3: Evaluate Each Finding

For every candidate, apply the uplift test and its Verify-first note, then record:

  • File and line — exact location
  • Current pattern — what the code does now
  • Suggested uplift — what it could become
  • Why — concrete benefit (fewer lines, illegal state now unrepresentable, eliminated null/partial-match bug class, clearer data flow)
  • Confidence — high (clear win, precondition confirmed), medium (judgment call), low (depends on context / needs new dep)

Step 4: Report

Consult references/pattern-catalog.md for detailed before/after examples to include in the report. Return the report as your final message — do not write it to a file; the caller relays it.


Pattern Detection Checklist

1. Pipeline, Composition & Partial Application

  • Nested function calls f (g (h x)) → x |> h |> g |> f (forward pipe; reads in execution order)
  • A let tmp = ... chain used only to thread one value → a |> pipeline
  • fun x -> f x wrapping a single call → f (eta-reduction); fun x -> g (f x) → f >> g
  • Partial application to capture config/dependencies — fun x -> validate rules x → let validate' = validate rules; a class that exists only to hold constructor args and expose one method → a partially-applied function. This is F#'s native substitute for constructor injection.
  • Verify-first (value restriction): eta-reducing a top-level let f = fun x -> g x to let f = g triggers the F# value restriction (FS0030) when the result is still generic and not immediately applied — let mapAll = List.map id fails to compile (a concrete inner function like List.map getName is fine). Only go point-free at top level when the type is fully concrete; eta-reduction inside a |> List.map (...) argument is always safe. When unsure, keep the explicit parameter.
  • Skip when: the result is point-free to the point of obscurity, or naming the intermediate value documents the step. Readability beats brevity.

2. Collection HOF Uplift

  • for/while loop pushing into a ResizeArray/mutable list → List.map/Array.map, or a comprehension [ for x in xs do if p x then yield f x ]
  • Filter-then-map in one step → List.choose when each element maps to an option
  • Accumulator loop (mutable total) → List.fold, List.sum/List.sumBy, List.max/List.min
  • Manual find loop with break → List.tryFind, List.tryPick, List.findIndex
  • Boolean scan → List.exists / List.forall
  • Nested loops flattening → List.collect
  • Manual grouping into a Dictionary → List.groupBy / List.countBy
  • Splitting by predicate → List.partition
  • Verify-first (iter, not map): a for loop whose body is a pure effect with no accumulated result maps to Seq.iter/List.iter/Array.iter — never map ... |> ignore, which allocates a throwaway result list.
  • Verify-first (preserve the module): keep the source collection's module — Array.* for arrays, List.* for lists. Converting an Array loop to List.* adds an allocation and turns O(1) indexing into O(n). Use Seq.* only for genuine laziness/streaming, and flag existing Seq chains over in-memory data that re-enumerate (each pass re-runs side effects and recomputes).
  • Verify-first (key equality): groupBy/countBy use default structural equality. If the original Dictionary used a custom comparer (e.g. StringComparer.OrdinalIgnoreCase), the HOF silently regroups — check the comparer first.
  • Skip when: loop body has early-exit control flow or several interacting effects a fold would obscure.

3. Option Combinator Uplift

  • match x with Some v -> Some (f v) | None -> None → Option.map f x
  • match x with Some v -> g v | None -> None (g returns option) → Option.bind g x
  • match x with Some v -> v | None -> d → Option.defaultValue d x
  • if isNull obj then None else Some obj → Option.ofObj obj (at .NET interop boundaries)
  • Deeply nested Some/None pyramids → an option { ... } computation expression (FsToolkit)
  • Verify-first (eager default): Option.defaultValue d x evaluates d unconditionally, even when x is Some. Only suggest it when d is a literal or already-bound value with no side effects. If the None branch calls a function, allocates, throws, or logs, you MUST suggest Option.defaultWith (fun () -> ...) instead. Same for Option.orElse/orElseWith and Result.defaultValue/defaultWith.
  • Verify-first (.Value may be intentional): .Value/.IsSome-then-.Value is sometimes a deliberate "this must be Some by construction" assertion. Replacing it with defaultValue d converts a loud failure into a silent substitution — never do this on a safety/auth/moderation/fail-closed path or where None indicates a real bug. There the correct uplift is an explicit match with a meaningful failure, not a default.
  • Skip when: the None branch has meaningfully different logic worth making explicit in a match.

4. Result & Railway-Oriented Uplift

  • match pyramids over Result<_,_> threading errors by hand → a result { ... } CE (FsToolkit.ErrorHandling)
  • Async/Task code threading Result → asyncResult { ... } / taskResult { ... }
  • A Result list that should collapse to Result<_ list, _> → List.sequenceResultM / List.traverseResultM
  • Exceptions used for ordinary, expected failures (validation, not-found, parse) → Result<'T, 'Error> with a DU error type
  • Independent validations that should accumulate all errors → validation { ... } (applicative let!/and!), returning Result<_, 'e list>
  • Verify-first (error-arm effects): a result/asyncResult CE discards the error to the caller via bind. If any Error/None arm of the original match does anything but return the error (logging, metrics, cleanup), the CE rewrite deletes those effects — flag as behavior change or leave it.
  • Verify-first (exception → Result): converting a throw to an Error changes stack unwinding. Confirm no caller relies on try/with/finally/use cleanup triggered by the exception.
  • Verify-first (validation constraints): the applicative validation CE only accumulates when the error type is a list/semigroup — it returns Result<_, 'e list>. Do not suggest it when validators return a single non-list error type; widening the public error type to a list is a breaking change, not an uplift. Also, and! evaluates every binding (no short-circuit) — if a validator hits a DB/network, accumulation changes runtime cost and behavior. Use it only for pure, independent checks.
  • Verify-first (dependency): every CE here requires FsToolkit.ErrorHandling to be already referenced (see Step 1).
  • Skip when: failures are truly exceptional (programmer error, unrecoverable) — those stay as exceptions.

5. Discriminated Union Uplift (make illegal states unrepresentable)

  • Multiple bool/optional fields where only certain combinations are valid → a DU whose cases carry exactly the data each state needs
  • A string/int field that only ever holds a fixed set of values → a discriminated union; matching becomes exhaustive
  • A primitive standing in for a domain concept (string email, decimal money, int userId) → a single-case DU with a validating constructor (type Email = private Email of string)
  • Enum + a switch/match repeated in many functions → DU + pattern match
  • Verify-first (closed set): only suggest a DU for a string when the value set is closed and known at compile time (a literal whitelist matched in code). Matching on externally-sourced strings — HTTP headers, file extensions, env vars, locale codes, JSON keys, search input — is correct as-is; a DU there just relocates the open-set problem to a parse step. Default to leaving string matches alone unless you can enumerate every legal value from the code.
  • Verify-first (allocation & serialization): a single-case DU is a heap-allocated reference type and is not transparently serialized/persisted — System.Text.Json, Dapper, EF, Marten, etc. need a custom converter or round-tripping breaks. Before wrapping a field that is persisted, sent over the wire, or used in a hot numeric loop: flag the converter/allocation cost, suggest [<Struct>] for value-like wrappers, and don't recommend the wrap across an ORM/JSON/interop boundary unless a converter lands in the same change.
  • Skip when: the set is genuinely open/dynamic, or the value is a pass-through with no domain meaning.

6. Pattern Matching, Exhaustiveness & Active Patterns

  • if/elif/else ladders over a single value → match (and let the compiler check exhaustiveness)
  • A lambda that is just match on its only argument → the function keyword (single-arg only)
  • A | _ -> ... wildcard over a closed DU → enumerate the cases instead, so adding a new case breaks the build rather than silently falling through. Hunt these: they are where DU evolution leaks bugs.
  • Case-name collisions across DUs → [<RequireQualifiedAccess>] on the DU
  • Repeated complex when guards or parse-and-test logic across many matches → a (partial) active pattern that names the concept ((|Int|_|), (|Regex|_|))
  • Skip when: an active pattern would be used once and a plain guard is clearer — active patterns should simplify, not add a layer.

7. Record & Immutability Uplift

  • A class holding only data with get/set properties → a record (structural equality, with-copy, conciseness)
  • Manual "clone then mutate one field" → copy-and-update { existing with Field = v }
  • A tuple with 3+ positional fields whose meaning isn't obvious → a record or anonymous record ({| ... |}) for local shapes
  • mutable fields updated in place where a new value would do → return a new record
  • Verify-first (equality semantics): a record imposes structural equality/hashing on all fields. Do NOT suggest it when the type is a dictionary key relying on reference identity, has a custom Equals/GetHashCode, carries a function-typed or non-comparable field (breaks auto-equality / won't compile), or structural comparison over many fields is a hot path — use [<ReferenceEquality>] or keep the class. Anonymous records are local shapes; don't return them across a module/assembly API.
  • Skip when: mutation is a deliberate, localized performance choice (hot loop, large buffer).

8. Units of Measure Uplift (F#-unique)

  • Numeric types where mixing units is a real bug risk — currency, time, distance, pixels — and arithmetic crosses unit boundaries → [<Measure>] types (float<m>, int<px>); the compiler then rejects metres + seconds
  • Verify-first (runtime-erased): measures are erased at runtime — they give zero protection at serialization/DB/wire boundaries (values return as bare float) and force float ↔ float<_> conversions there, often degrading inference. Only suggest them for quantities that live and do arithmetic inside F# code.
  • Skip when: the number is dimensionless or never combined with other quantities.

9. Async / Task Uplift

  • Sequential independent let! awaits → concurrent fan-out. For same-typed work over a collection, Async.Parallel; for a few heterogeneous asyncs, Async.StartChild (preserves distinct types and the tuple result — see catalog).
  • Async.RunSynchronously called inside an async/task block → let!/do!
  • Hand-rolled async error threading → asyncResult/taskResult (see §4)
  • Verify-first (RunSynchronously is sometimes correct): at a synchronous boundary you cannot change — main, an interface member whose signature is sync, a constructor, a test, a Lazy<_> — Async.RunSynchronously is the right call. Confirm the call is lexically inside an async CE before flagging; never suggest making a sync interface member async just to remove it.
  • Verify-first (Parallel resource/exception semantics): Async.Parallel launches all work at once — for large fan-out it can exhaust connection pools or trip rate limits (use Async.Parallel(..., maxDegreeOfParallelism = n)), and it changes failure semantics (runs all, aggregates exceptions) vs sequential let! (stops at first). Flag the tuple→array shape change at call sites.
  • async vs task: prefer async for most F# code — cold, compositional, supports async try/finally. Recommend task when Task-returning .NET interop dominates, for hot-start, or the perf/debug edge. task does not support tail calls — don't suggest it for deep recursive async loops.
  • Skip when: the awaits are genuinely dependent (each needs the previous result).

10. OO-Residue & Resource Uplift (common in C#-to-F# code)

  • A class with one public method and a constructor → a curried let function (constructor args become leading parameters; partially apply — see §1)
  • A one-off interface implementation → an object expression ({ new IFoo with member _.Bar() = ... })
  • Static utility class of methods → a module of let-bound functions
  • null returns/params on the F# side → Option<'T>; convert at the boundary with Option.ofObj/Option.toObj
  • Manual try/finally x.Dispose() (or a missing dispose) → use x = ... (and use! in async/task CEs)
  • Verify-first (MailboxProcessor is architecture, not uplift): mention a MailboxProcessor (agent) for a lock-guarded mutable service only as a consider, never a concrete rewrite — it serializes all access (throughput ceiling) and adds lifecycle/supervision/error-routing concerns, and it fails the four-gate when the codebase already has an established locking/concurrency convention.
  • Skip when: the type must satisfy a .NET framework contract (inheritance, attributes, designer support) requiring a class.

11. Computation Expressions, Ecosystem & Misc

  • Nested map/filter/collect with interleaved logic or multiple yields → a comprehension [ for ... do ... yield ... ] / seq { }
  • Hand-built IQueryable/LINQ expression trees → a query { ... } CE
  • A recurring hand-rolled bind/return shape at ≥2–3 sites with real domain meaning → a custom builder (gate hard; rarely worth it)
  • Manual env.GetCommandLineArgs / hand-parsed argv → Argu (note: Argu's Parse expects argv without the executable name)
  • Hand-written JSON glue → FSharp.SystemTextJson or Thoth.Json (DU/option-aware)
  • Redundant explicit type annotations the compiler can infer → drop them (keep annotations on public API signatures)
  • sprintf "%s..." / concatenation → interpolated strings $"...". Verify-first: sprintf/printfn specifiers (%d, %A, %0.2f) are type-checked and apply F# formatting; plain $"{x}" calls ToString() and loses both. Only swap for %s/%d/%b of already-correctly-typed values with no width/precision/%A; use typed interpolation $"%0.2f{x}" to preserve a specifier, and never convert a %A away.
  • Hard-coded member/field name strings → nameof (note: nameof gives the F# member name, which may differ from a [<JsonPropertyName>] wire name)
  • Skip when: adding a dependency isn't worth it for a tiny project or a minimal-deps policy.

Decision Framework: Recommend vs. Leave Alone

The uplift test covers the fundamentals. This matrix adds situational factors:

FactorLean toward upliftLean toward leaving
Appears inMultiple placesOne place only
Current code isImperative/verbose, hard to followClear despite being plain
Abstraction isMainstream F# idiomClever, point-free, or custom-operator
Type inferencePreserved or improvedWould need new annotations to compile
Evaluation/effectsProvably unchangedCould shift eager↔lazy, reorder/drop effects
BoundaryPure in-process F#Crosses ORM / JSON / wire / interop
DependencyAlready referencedWould add a new package
Code locationLibrary / domain coreOne-off script / .fsx / test

When factors conflict, readability wins. If plain code is perfectly clear, don't functional-ize it for its own sake. If imperative code is confusing and a well-known idiom clarifies it, recommend it even for a single occurrence.

The point-free trap: F# makes it tempting to stack operators. Cognitive load is real and one-directional — going from a simple named pipeline to a clever composition is easy; reading it back later is not. When a point-free rewrite isn't obviously clearer, the version with named values is the right answer.

Effects and laziness are load-bearing: converting List↔Seq, or match↔a short-circuiting CE, changes when, whether, and how often code runs. Treat any such change as a behavior change, not a style change — only recommend it with that called out explicitly.


Output Format

# F# Uplift Report

**Scope:** [files/dirs analyzed]
**Files scanned:** [count]
**Findings:** [N high-confidence, M medium, L low]

## High-Impact Findings

### 1. [src/Order.fs:42-58] — Collection HOF Uplift
**Current:** Imperative `for` loop with a `mutable` accumulator and a `ResizeArray`
**Suggested:** `List.choose` to filter-and-map in one pass
**Why:** Removes mutation, 12 lines → 3, intent (keep valid, transform) is immediate
**Confidence:** High (precondition confirmed: loop body is pure, builds one list)

\```fsharp
// Before
let results = ResizeArray()
for item in items do
    if item.IsValid then
        results.Add(transform item)
let results = List.ofSeq results

// After
let results =
    items |> List.choose (fun item ->
        if item.IsValid then Some (transform item) else None)
\```

---

## Summary

[1-2 sentences on overall codebase state]
[Cross-cutting observations, e.g. "Adopting FsToolkit.ErrorHandling's `result` CE (already referenced) would replace hand-threaded Result matches across 4 modules" or "Several domain primitives are bare strings — single-case DUs would catch a class of mix-up bugs, but check Marten serialization first"]

Anti-Hallucination Rules

  • Every finding MUST reference a specific file and line range. No "throughout the codebase" findings.
  • If you cannot find the pattern in actual code, do not report it.
  • "No findings — this codebase is already idiomatic F#" is valid output.
  • Mark confidence honestly, and only mark High when the pattern's Verify-first precondition is confirmed from the code.
  • Do not recommend libraries or APIs you haven't verified exist, nor syntax newer than the project's <LangVersion>/<TargetFramework>. A CE/library finding is at most low-confidence unless the package is already referenced.
  • Never recommend a change that shifts eager↔lazy evaluation, reorders or drops effects, alters value/equality semantics, or changes exception-vs-Result propagation without explicitly flagging it as a behavior change.
  • Do not recommend point-free rewrites, custom operators, or exotic abstractions purely to look clever — they fail the Convention gate.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.