Skip to content

Add stable versioned API#289

Draft
Smaug123 wants to merge 17 commits intomainfrom
versioned-api
Draft

Add stable versioned API#289
Smaug123 wants to merge 17 commits intomainfrom
versioned-api

Conversation

@Smaug123
Copy link
Contributor

@Smaug123 Smaug123 commented Feb 27, 2026

Ref: #288

The change is backward-compatible, as is demonstrated by the fact that the old OptionAnalyzer continues to work (though we don't demonstrate that the ongoing forward-compatibility guarantee is satisfied). The old API is now interpreted as the "bleeding-edge" API, and analyzers written against it are liable to be broken by any version upgrade just as they are right now.

We supply adapters for every stable API we offer: FSharp.Analyzers.SDK reads in the source using FCS's most recent types, and then projects them back down to each stable API as necessary.

Claude explicitly chose to make the CliContext and EditorContext be the same type. I don't have a strong opinion about that; I've never written an editor analyzer.

Open problem: what should the versioned namespaces be called?

Right now I haven't read this code at all; consider it completely unsuited for review unless you're seriously interested in LLM slop. It's Claude Opus 4.6.

Here's the plan Claude was following.# Versioned Analyzer API for FSharp.Analyzers.SDK

Context

When the SDK changes version, all analyzers must be rebuilt. The root causes:

  1. Strict version gate: Client.fs:261-263 rejects any analyzer whose SDK reference doesn't match Major.Minor exactly.
  2. Raw FCS types in public API: CliContext, EditorContext, walkers, Message.Range, Fix.FromRange all expose FCS types (FSharpCheckFileResults, FSharpExpr, FSharpType, range, etc.) directly. An FCS upgrade changes the SDK's public surface, breaking all analyzers even if the SDK API itself didn't change.

The goal: allow an analyzer compiled against SDK V(N) to continue working when loaded by SDK V(M) where M > N, without recompilation.

Architecture Overview

Append-only versioned namespaces (FSharp.Analyzers.SDK.V1, .V2, ...), each containing a complete, frozen set of SDK-owned types that analyzer authors code against. No FCS types cross the boundary into a versioned namespace.

  • All version namespaces live in the same FSharp.Analyzers.SDK.dll assembly. Analyzer authors reference the SDK NuGet package and open the specific version namespace.
  • Once frozen, a version namespace's types never change (append-only at the namespace level, immutable within each namespace).
  • The SDK runtime holds current FCS data internally. When loading a V(N) analyzer:
    • Input: projects current FCS data into V(N) types to construct the V(N) context.
    • Output: converts V(N) Message results into the current internal representation.
  • The existing unversioned API (FSharp.Analyzers.SDK.CliAnalyzerAttribute, etc.) continues to work as-is for backward compatibility, still subject to the strict version check.

Data over behaviour

The current walker base classes (SyntaxCollectorBase, TypedTreeCollectorBase) are abstract classes with virtual methods -- a behavioral abstraction. For V1, replace this with a data-oriented typed tree: the SDK converts FCS's FSharpImplementationFileContents into an SDK-owned DU (TypedExpr, TypedDeclaration) that analyzer authors traverse by pattern matching. A convenience visit function can be layered on top for those who prefer the callback style, but the primary API is the data structure.

This aligns with "compute a description of what to do, then do it" and makes the tree inspectable, serialisable, and property-testable.

V1 Type Design

Source range (decoupling from FCS range)

namespace FSharp.Analyzers.SDK.V1

type SourceRange = {
    FileName: string
    StartLine: int      // 1-based
    StartColumn: int    // 0-based
    EndLine: int
    EndColumn: int
}

This is the single most impactful decoupling: range currently appears in Message, Fix, and ~10 walker callback signatures.

Output types

type Severity = Info | Hint | Warning | Error

type Fix = { FromRange: SourceRange; FromText: string; ToText: string }

type Message = {
    Type: string; Message: string; Code: string
    Severity: Severity; Range: SourceRange; Fixes: Fix list
}

Structurally identical to the current types but using SourceRange instead of FCS range.

TAST mirror types

SDK-owned records projecting the FCS class properties that analyzers read. The set of properties is chosen by surveying the walker callback parameters and the OptionAnalyzer sample.

Key types (illustrative, not exhaustive field lists):

SDK V1 type Mirrors FCS type Key fields
EntityInfo FSharpEntity FullName, DisplayName, CompiledName, Namespace, DeclaringEntity, IsModule/Union/Record/Class/Enum/Interface/ValueType/AbstractClass
TypeInfo FSharpType BasicQualifiedName, IsAbbreviation, IsFunctionType, IsTupleType, IsGenericParameter, GenericArguments, AbbreviatedType, TypeDefinition
MemberOrFunctionOrValueInfo FSharpMemberOrFunctionOrValue DisplayName, FullName, CompiledName, IsProperty/Method/CompilerGenerated/Mutable/ExtensionMember/ActivePattern/Constructor, DeclaringEntity, FullType, CurriedParameterGroups
FieldInfo FSharpField Name, FieldType, IsCompilerGenerated, IsMutable, DeclaringEntity
UnionCaseInfo FSharpUnionCase Name, CompiledName, Fields, ReturnType
GenericParameterInfo FSharpGenericParameter Name, IsSolveAtCompileTime
ObjectExprOverrideInfo FSharpObjectExprOverride Signature (as MemberOrFunctionOrValueInfo), Body (as TypedExpr)
MemberFlags SynMemberFlags IsInstance, IsDispatchSlot, IsOverrideOrExplicitImpl, IsFinal, MemberKind

Handling recursive type graphs: The conversion function uses an internal memoisation cache keyed by FCS object reference identity. When an entity/type is encountered a second time during the same conversion walk, a stub is returned (FullName populated, structural children set to empty/None). This prevents infinite recursion while preserving the most important data (names, which is what analyzers typically read from transitive references).

Typed expression tree (DU)

Replace the walker's 40 abstract methods with a single DU. Each case mirrors one branch of visitExpr in TASTCollecting.fs:264-439:

type TypedExpr =
    | AddressOf of lvalueExpr: TypedExpr
    | AddressSet of lvalueExpr: TypedExpr * rvalueExpr: TypedExpr
    | Application of funcExpr: TypedExpr * typeArgs: TypeInfo list * argExprs: TypedExpr list
    | Call of objExpr: TypedExpr option * memberOrFunc: MemberOrFunctionOrValueInfo
            * objTypeArgs: TypeInfo list * memberTypeArgs: TypeInfo list
            * argExprs: TypedExpr list * range: SourceRange
    | Coerce of targetType: TypeInfo * expr: TypedExpr
    | FastIntegerForLoop of start: TypedExpr * limit: TypedExpr * consume: TypedExpr * isUp: bool
    | IfThenElse of guard: TypedExpr * thenExpr: TypedExpr * elseExpr: TypedExpr
    | Lambda of var: MemberOrFunctionOrValueInfo * body: TypedExpr
    | Let of binding: MemberOrFunctionOrValueInfo * bindingExpr: TypedExpr * body: TypedExpr
    | LetRec of bindings: (MemberOrFunctionOrValueInfo * TypedExpr) list * body: TypedExpr
    | NewArray of arrayType: TypeInfo * args: TypedExpr list
    | NewDelegate of delegateType: TypeInfo * body: TypedExpr
    | NewObject of ctor: MemberOrFunctionOrValueInfo * typeArgs: TypeInfo list * args: TypedExpr list
    | NewRecord of recordType: TypeInfo * args: TypedExpr list * range: SourceRange
    | NewTuple of tupleType: TypeInfo * args: TypedExpr list
    | NewUnionCase of unionType: TypeInfo * case: UnionCaseInfo * args: TypedExpr list
    | Quote of expr: TypedExpr
    | FieldGet of objExpr: TypedExpr option * recordOrClassType: TypeInfo * field: FieldInfo
    | FieldSet of objExpr: TypedExpr option * recordOrClassType: TypeInfo * field: FieldInfo * value: TypedExpr
    | Sequential of first: TypedExpr * second: TypedExpr
    | TryFinally of body: TypedExpr * finalizer: TypedExpr
    | TryWith of body: TypedExpr * filterVar: MemberOrFunctionOrValueInfo * filterExpr: TypedExpr
              * catchVar: MemberOrFunctionOrValueInfo * catchExpr: TypedExpr
    | TupleGet of tupleType: TypeInfo * index: int * tuple: TypedExpr
    | DecisionTree of decision: TypedExpr * targets: (MemberOrFunctionOrValueInfo list * TypedExpr) list
    | DecisionTreeSuccess of targetIdx: int * targetExprs: TypedExpr list
    | TypeLambda of genericParams: GenericParameterInfo list * body: TypedExpr
    | TypeTest of ty: TypeInfo * expr: TypedExpr
    | UnionCaseSet of unionExpr: TypedExpr * unionType: TypeInfo * case: UnionCaseInfo * field: FieldInfo * value: TypedExpr
    | UnionCaseGet of unionExpr: TypedExpr * unionType: TypeInfo * case: UnionCaseInfo * field: FieldInfo
    | UnionCaseTest of unionExpr: TypedExpr * unionType: TypeInfo * case: UnionCaseInfo
    | UnionCaseTag of unionExpr: TypedExpr * unionType: TypeInfo
    | ObjectExpr of objType: TypeInfo * baseCall: TypedExpr * overrides: ObjectExprOverrideInfo list
                  * interfaceImpls: (TypeInfo * ObjectExprOverrideInfo list) list
    | TraitCall of sourceTypes: TypeInfo list * traitName: string * memberFlags: MemberFlags
                 * typeInstantiation: TypeInfo list * argTypes: TypeInfo list * argExprs: TypedExpr list
    | ValueSet of valToSet: MemberOrFunctionOrValueInfo * value: TypedExpr
    | WhileLoop of guard: TypedExpr * body: TypedExpr
    | BaseValue of baseType: TypeInfo
    | DefaultValue of defaultType: TypeInfo
    | ThisValue of thisType: TypeInfo
    | Const of value: obj * constType: TypeInfo
    | Value of valueToGet: MemberOrFunctionOrValueInfo
    | ILAsm of asmCode: string * typeArgs: TypeInfo list * argExprs: TypedExpr list
    | ILFieldGet of objExpr: TypedExpr option * fieldType: TypeInfo * fieldName: string
    | ILFieldSet of objExpr: TypedExpr option * fieldType: TypeInfo * fieldName: string * value: TypedExpr
    | Unknown of range: SourceRange

type TypedDeclaration =
    | Entity of entity: EntityInfo * subDeclarations: TypedDeclaration list
    | MemberOrFunctionOrValue of value: MemberOrFunctionOrValueInfo
        * curriedArgs: MemberOrFunctionOrValueInfo list list * body: TypedExpr
    | InitAction of expr: TypedExpr

This mirrors exactly the structure that visitExpr and visitDeclaration in TASTCollecting.fs already decompose. The Unknown case is a catch-all for any FCS expression form not present in V1 (forward-compatible).

Typed tree conversion function (the primary API)

module FSharp.Analyzers.SDK.V1.TASTCollecting

/// Convert the typed tree into SDK-owned V1 types.
/// This is the main entry point for TAST-based analyzers.
val convertTast: tast: TypedTreeHandle -> TypedDeclaration list

The TypedTreeHandle is an opaque wrapper around FSharpImplementationFileContents that lives in the V1 context. Analyzer authors call convertTast and then traverse the returned TypedDeclaration list by pattern matching.

Optional convenience: a visit function that walks the tree and calls a handler, for those who prefer the callback pattern:

/// Walk the typed tree and call handler for each expression node.
val visitTypedTree: handler: (TypedExpr -> unit) -> declarations: TypedDeclaration list -> unit

Context types

type CliContext = {
    FileName: string
    SourceText: string
    TypedTree: TypedTreeHandle option
    ProjectOptions: ProjectOptionsInfo
    AnalyzerIgnoreRanges: Map<string, AnalyzerIgnoreRange list>
    SymbolUsesInFile: SymbolUseInfo list
    SymbolUsesInProject: SymbolUseInfo list
}

Where:

  • TypedTreeHandle is opaque; passed to convertTast.
  • ProjectOptionsInfo is an SDK-owned record: { ProjectFileName; SourceFiles; ReferencedProjectsPaths; OtherOptions }.
  • SymbolUseInfo captures { Symbol: SymbolInfo; Range: SourceRange; IsFromDefinition; IsFromPattern; ... }.
  • No FSharpParseFileResults, FSharpCheckFileResults, or FSharpCheckProjectResults. Their useful data is pre-extracted into the context fields above (symbol uses, diagnostics, ignore ranges). If additional data is needed, add specific fields in V2.

EditorContext: deferred to V2. V1 covers CLI analyzers only.

Attributes and analyzer type

type Analyzer = CliContext -> Async<Message list>

[<AttributeUsage(...)>]
type CliAnalyzerAttribute(name, shortDescription, helpUri) =
    inherit System.Attribute()
    member Name: string
    member ShortDescription: string option
    member HelpUri: string option

Note: V1.CliAnalyzerAttribute is a separate type from the current FSharp.Analyzers.SDK.CliAnalyzerAttribute. The loader distinguishes them by checking the attribute's declaring namespace.

Migration Mechanism

Input projection (current FCS -> V1)

Internal module FSharp.Analyzers.SDK.Migration.V1:

  • rangeToV1: FSharp.Compiler.Text.range -> V1.SourceRange -- field-by-field copy
  • entityToV1: FSharpEntity -> V1.EntityInfo -- read properties, memoise by identity
  • typeToV1: FSharpType -> V1.TypeInfo -- recursive, memoised
  • memberToV1: FSharpMemberOrFunctionOrValue -> V1.MemberOrFunctionOrValueInfo
  • exprToV1: FSharpExpr -> V1.TypedExpr -- mirrors visitExpr structure
  • contextToV1: CliContext -> V1.CliContext -- constructs V1 context, wraps TypedTree in handle

The exprToV1 function is structurally identical to the existing visitExpr match expression (TASTCollecting.fs:266-439), but returns TypedExpr DU values instead of calling walker methods. This is the key observation: the conversion function is almost a copy of the existing code with a different output type.

Output migration (V1 -> current)

  • rangeFromV1: V1.SourceRange -> range -- Range.mkRange + Position.mkPos
  • messageFromV1: V1.Message -> Message -- map fields, convert severity and range
  • fixFromV1: V1.Fix -> Fix -- map fields

Future versions (V2, V3, ...)

When V2 is created:

  • V2 types may add fields to records (new properties on FCS types), new cases to TypedExpr (new FCS expression forms), or new context fields.
  • Migration: V2 -> current is direct. V1 -> current stays as-is (V1 types are frozen, the V1 migration code is frozen).
  • No need to chain V1 -> V2 -> current; each version has a direct projection from current and a direct migration to current. The set of migrations grows linearly with the number of versions.

Loader Changes

File: src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.Client.fs

Version detection

The isAnalyzer function (Client.fs:31-34) checks attributes by type name. Extend it to check for V1 attributes:

let isV1CliAnalyzer (mi: MemberInfo) =
    mi.GetCustomAttributes true
    |> Array.tryFind (fun a -> a.GetType().FullName = "FSharp.Analyzers.SDK.V1.CliAnalyzerAttribute")
    |> Option.map unbox<V1.CliAnalyzerAttribute>

Adapter wrapping

When a V1 analyzer is detected, wrap it as a current-version analyzer:

let adaptV1Analyzer (v1Analyzer: V1.Analyzer) : Analyzer<CliContext> =
    fun ctx -> async {
        let v1Ctx = Migration.V1.contextToV1 ctx
        let! v1Messages = v1Analyzer v1Ctx
        return v1Messages |> List.map Migration.V1.messageFromV1
    }

The adapted analyzer is stored in the same registeredAnalyzers dictionary as current-version analyzers. From the runner's perspective, all analyzers are Analyzer<CliContext>.

Version check relaxation

For V1+ analyzers (detected by versioned attributes), skip the Major.Minor version check entirely. The versioned types provide the compatibility guarantee. The strict check remains for unversioned (legacy) analyzers.

AST Support

Deferred to V2. Rationale:

  • Most real-world analyzers use the TAST walker (the typed tree has strictly more information).
  • The AST types (SynExpr ~70 cases, SynPat ~20, SynType ~20, plus auxiliaries) are much larger than the TAST expression types (~40 cases).
  • AST types are quite stable across FCS versions, so the coupling is less painful in practice.
  • Deferring halves the initial scope.

When V2 adds AST support, it will define an SDK-owned SyntaxExpr DU (and friends) mirroring the Syn* types, with a convertAst function analogous to convertTast.

Implementation Phases

Phase 1: V1 type definitions

New files in src/FSharp.Analyzers.SDK/:

  • V1.Types.fs -- SourceRange, Severity, Fix, Message, EntityInfo, TypeInfo, MemberOrFunctionOrValueInfo, FieldInfo, UnionCaseInfo, GenericParameterInfo, MemberFlags, ObjectExprOverrideInfo, TypedExpr, TypedDeclaration, SymbolUseInfo, SymbolInfo, ProjectOptionsInfo, CliContext, TypedTreeHandle, AnalyzerIgnoreRange
  • V1.Types.fsi -- public API surface
  • V1.Attributes.fs -- CliAnalyzerAttribute, Analyzer type alias

Phase 2: Conversion functions

  • V1.Migration.fs -- internal module: rangeToV1, entityToV1, typeToV1, memberToV1, exprToV1, contextToV1, rangeFromV1, messageFromV1

The exprToV1 function is modelled directly on TASTCollecting.fs:visitExpr (same match structure, different output).

Phase 3: V1 TAST API

  • V1.TASTCollecting.fs -- convertTast (unwraps TypedTreeHandle, calls conversion), optional visitTypedTree convenience function

Phase 4: Loader integration

  • Modify Client.fs: add V1 attribute detection, adapter wrapping, relaxed version check for versioned analyzers.

Phase 5: Testing

  • Port the OptionAnalyzer sample to V1 as samples/OptionAnalyzer.V1/ -- validates the API is usable.
  • Property-based tests:
    • For every field of every mirror type: projectToV1(fcsValue).Field = fcsValue.Field (conversion preserves data).
    • rangeFromV1(rangeToV1(r)) = r (round-trip).
    • messageFromV1(messageToV1(m)) preserves all fields.
    • Integration test: load a V1 analyzer and a current-version analyzer in the same Client, run both on the same file, verify both produce correct results.

Phase 6: Freeze V1

  • Review the V1 type surface with real analyzer authors.
  • Once released, V1 namespace types never change.

Transition for Existing Analyzer Authors

  • No forced migration: the unversioned API continues to work (with the same version-check semantics).
  • Opt-in to stability: to gain version-independent loading, an analyzer author:
    1. Opens FSharp.Analyzers.SDK.V1 instead of FSharp.Analyzers.SDK.
    2. Uses V1.CliAnalyzerAttribute and V1.Analyzer.
    3. Uses V1.CliContext, V1.Message, V1.SourceRange etc.
    4. Calls V1.TASTCollecting.convertTast to get the typed tree and pattern-matches on TypedExpr.
    5. Removes direct dependency on FSharp.Compiler.Service (no more open FSharp.Compiler.*).
  • The migration is per-analyzer, not all-or-nothing.

Example: the OptionAnalyzer rewritten for V1:

open FSharp.Analyzers.SDK.V1
open FSharp.Analyzers.SDK.V1.TASTCollecting

let rec findOptionValue (expr: TypedExpr) (results: ResizeArray<SourceRange>) =
    match expr with
    | Call(_, m, _, _, _, range)
        when m.DeclaringEntity
             |> Option.exists (fun de ->
                 String.Join(".", de.FullName, m.DisplayName)
                 = "Microsoft.FSharp.Core.FSharpOption`1.Value") ->
        results.Add range
    | _ -> ()
    // visitTypedTree handles recursion, or the analyzer can recurse manually

[<CliAnalyzer "OptionAnalyzer">]
let optionValueAnalyzer: Analyzer =
    fun ctx -> async {
        let results = ResizeArray()
        match ctx.TypedTree with
        | None -> ()
        | Some handle ->
            let tree = convertTast handle
            visitTypedTree (fun expr -> findOptionValue expr results) tree
        return results |> Seq.map (fun r ->
            { Type = "Option.Value analyzer"; Message = "Option.Value shouldn't be used"
              Code = "OV001"; Severity = Severity.Warning; Range = r; Fixes = [] })
            |> Seq.toList
    }

Risks and Mitigations

Risk Mitigation
Mirror types miss properties that real analyzers need Survey existing open-source analyzers before freezing V1. Include more properties than fewer -- they're cheap.
Recursive FCS type graphs cause infinite conversion Memoisation cache keyed by FCS object identity. Stubs returned for already-visited entities.
Conversion overhead on every analysis run Benchmark. The typed tree conversion is O(n) in AST size and likely negligible vs. type-checking. Memoisation helps.
V1 surface is too narrow, forcing frequent V2 Design V1 generously. Include all properties accessible on the FCS types, not just the ones the sample analyzer uses.
Binary compatibility of frozen namespaces Standard .NET binary compat rules. Records and DUs are safe as long as you don't add/remove fields/cases. CI should include a binary-compat check (e.g., loading a V1 analyzer compiled against an older SDK version).

Critical Files

  • src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fs -- current types to mirror
  • src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fsi -- current public API surface
  • src/FSharp.Analyzers.SDK/TASTCollecting.fs -- visitExpr (lines 264-439) is the template for the exprToV1 conversion function
  • src/FSharp.Analyzers.SDK/TASTCollecting.fsi -- current walker API to replace
  • src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.Client.fs -- loader (version check at lines 258-278, attribute detection at lines 31-34)
  • src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.Client.fsi -- client API surface
  • samples/OptionAnalyzer/Library.fs -- reference analyzer to port as validation
  • src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fsproj -- add new V1 source files


### Changed

- We now expose stable backward-compatible APIs, independent of the FSharp.Compiler.Service API, which you are encouraged to use. If you use the stable API, then upgrading FSharp.Analyzers.SDK will not require you to rebuild your analyzers until you need to consume features from later versions of the SDK. (For example, if a new F# language version introduces new syntax that you wish to parse, then you will have to upgrade your analyzer to a later stable API version which understands that new syntax.)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As of f0e4c3d this is the only part of the code that I wrote. (It's also all I've even read, so far.)

<ProjectReference Include="..\..\src\FSharp.Analyzers.SDK.Testing\FSharp.Analyzers.SDK.Testing.fsproj" />
<ProjectReference Include="..\..\src\FSharp.Analyzers.SDK\FSharp.Analyzers.SDK.fsproj" />
<ProjectReference Include="..\OptionAnalyzer\OptionAnalyzer.fsproj" />
<ProjectReference Include="..\OptionAnalyzer.V1\OptionAnalyzer.V1.fsproj" />
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cloned the existing OptionAnalyzer but now coding it against the stable API rather than against the raw FCS API.

@@ -0,0 +1,818 @@
module internal FSharp.Analyzers.SDK.AdapterV1
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FSharp.Analyzers.SDK has an FCS-derived view of the world. In order to call out to the user-supplied analyzer, it goes through an adapter to represent the world in terms of the stable V1 types.

Release|x86 = Release|x86
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Copy link
Contributor Author

@Smaug123 Smaug123 Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears to be the default, not that it seems to be documented anywhere. Anyway, Claude chose for inscrutable reasons to move it down the file.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should move to slnx anyway

|> Array.tryFind (fun n -> n.GetType().Name = typeof<'TAttribute>.Name)
|> Option.map unbox<'TAttribute>
|> Option.bind (fun attr ->
try
Copy link
Contributor Author

@Smaug123 Smaug123 Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was always wrong: if a user defines an attribute with the same name as an FCS one, this would break on main.

GenericParameters: GenericParameterInfo list
}

and TypedExpr =
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude was of the opinion, and I agree, that the AST-walking visitor style is less ergonomic than simply exposing the DUs. We could expose tree-walking style APIs, though, if we wanted to make migration easier.

else
[]

// Legacy analyzers: version check required.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not so keen on calling this "legacy" - it's really "bleeding-edge will-break-at-the-drop-of-a-hat".

<Tailcalls>true</Tailcalls>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="OptionAnalyzer.Test" />
Copy link
Contributor Author

@Smaug123 Smaug123 Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh nooooo - I think this is correct, though; we don't want to make the adapter modules visible, but we do want to test them.

/// A V1 analyzer defined as a method with inferred return type (returns [] without
/// explicit type annotation). This exercises the same codepath that the legacy loader
/// handles via its generic-return-type fallback.
[<CliAnalyzer "InferredReturnAnalyzer">]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested in the test "LoadAnalyzers discovers V1 analyzer with inferred return type"


let mutable projectOptions: FSharpProjectOptions = Unchecked.defaultof<_>

[<OneTimeSetUp>]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I switched to OneTimeSetUp, by the way, which I think is more appropriate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants