Skip to content

Commit 41288c1

Browse files
authored
feat(type-provider): add FScript export type provider with strict runtime signature checks (#2)
## Summary Adds a new `MagnusOpera.FScript.TypeProvider` package that projects `[<export>]` FScript functions into strongly-typed F# static methods. The provider: - parses and type-checks scripts at compile time, - fails F# compilation on script parse/type errors, - exposes exported functions with mapped argument/return types, - supports runtime script replacement through a provider-level resolver, - enforces strict runtime signature compatibility via compile-time fingerprint checks. ## Main changes - New project: `src/FScript.TypeProvider` - `Provider.fs` (type provider entry + generated members) - `Contract.fs` (type mapping, fingerprinting, serialized contract) - `ScriptRuntime.fs` (extern resolution, runtime loading, invocation, compatibility checks) - Runtime extraction helper: `src/FScript.Runtime/ExportSignatures.fs` - `ScriptHost` now reuses this shared exported-signature extraction path. - Solution wiring: - added `FScript.TypeProvider` and `FScript.TypeProvider.Tests` to `FScript.sln`. ## Supported exported signature shapes (v1) - `unit`, `int` (`int64`), `float`, `bool`, `string` - `list<T>`, `option<T>`, tuples (`2..8`), `map<string, T>` Unsupported exported types fail provider generation (records/unions/named types/function values/unresolved generics/non-string map keys). ## Tests Added integration coverage in `tests/FScript.TypeProvider.Tests` with fixture projects: - valid script compiles and exposes callable members, - compile fails on script type errors, - compile fails on unsupported exported signature shapes, - runtime override succeeds with compatible signatures and fails on signature mismatch. ## Docs + release notes - Added spec: `docs/specs/fsharp-type-provider.md` - Updated: - `docs/specs/README.md` - `docs/specs/embedding-fscript-language.md` - `docs/architecture/assemblies-and-roles.md` - Updated `CHANGELOG.md` (`[Unreleased]`). ## Validation - `make build` - `make test` - `make smoke-tests`
1 parent dfa0a4b commit 41288c1

28 files changed

Lines changed: 1230 additions & 38 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ All notable changes to FScript are documented in this file.
44

55
## [Unreleased]
66

7+
- Added a new `MagnusOpera.FScript.TypeProvider` package that type-checks scripts at compile time and exposes exported functions as strongly-typed F# members with runtime signature compatibility checks.
8+
79
## [0.59.0]
810

911

FScript.sln

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FScript.LanguageServer", "s
2525
EndProject
2626
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FScript.Cli.Tests", "tests\FScript.Cli.Tests\FScript.Cli.Tests.fsproj", "{9B840598-3B03-457B-B1BE-9701BFD0D40A}"
2727
EndProject
28+
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FScript.TypeProvider", "src\FScript.TypeProvider\FScript.TypeProvider.fsproj", "{14D91D30-8E5E-482A-940B-CC55F2DE80AA}"
29+
EndProject
30+
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FScript.TypeProvider.Tests", "tests\FScript.TypeProvider.Tests\FScript.TypeProvider.Tests.fsproj", "{42D043DE-8987-4072-8841-DCB2144AC18C}"
31+
EndProject
2832
Global
2933
GlobalSection(SolutionConfigurationPlatforms) = preSolution
3034
Debug|Any CPU = Debug|Any CPU
@@ -143,6 +147,30 @@ Global
143147
{9B840598-3B03-457B-B1BE-9701BFD0D40A}.Release|x64.Build.0 = Release|Any CPU
144148
{9B840598-3B03-457B-B1BE-9701BFD0D40A}.Release|x86.ActiveCfg = Release|Any CPU
145149
{9B840598-3B03-457B-B1BE-9701BFD0D40A}.Release|x86.Build.0 = Release|Any CPU
150+
{14D91D30-8E5E-482A-940B-CC55F2DE80AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
151+
{14D91D30-8E5E-482A-940B-CC55F2DE80AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
152+
{14D91D30-8E5E-482A-940B-CC55F2DE80AA}.Debug|x64.ActiveCfg = Debug|Any CPU
153+
{14D91D30-8E5E-482A-940B-CC55F2DE80AA}.Debug|x64.Build.0 = Debug|Any CPU
154+
{14D91D30-8E5E-482A-940B-CC55F2DE80AA}.Debug|x86.ActiveCfg = Debug|Any CPU
155+
{14D91D30-8E5E-482A-940B-CC55F2DE80AA}.Debug|x86.Build.0 = Debug|Any CPU
156+
{14D91D30-8E5E-482A-940B-CC55F2DE80AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
157+
{14D91D30-8E5E-482A-940B-CC55F2DE80AA}.Release|Any CPU.Build.0 = Release|Any CPU
158+
{14D91D30-8E5E-482A-940B-CC55F2DE80AA}.Release|x64.ActiveCfg = Release|Any CPU
159+
{14D91D30-8E5E-482A-940B-CC55F2DE80AA}.Release|x64.Build.0 = Release|Any CPU
160+
{14D91D30-8E5E-482A-940B-CC55F2DE80AA}.Release|x86.ActiveCfg = Release|Any CPU
161+
{14D91D30-8E5E-482A-940B-CC55F2DE80AA}.Release|x86.Build.0 = Release|Any CPU
162+
{42D043DE-8987-4072-8841-DCB2144AC18C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
163+
{42D043DE-8987-4072-8841-DCB2144AC18C}.Debug|Any CPU.Build.0 = Debug|Any CPU
164+
{42D043DE-8987-4072-8841-DCB2144AC18C}.Debug|x64.ActiveCfg = Debug|Any CPU
165+
{42D043DE-8987-4072-8841-DCB2144AC18C}.Debug|x64.Build.0 = Debug|Any CPU
166+
{42D043DE-8987-4072-8841-DCB2144AC18C}.Debug|x86.ActiveCfg = Debug|Any CPU
167+
{42D043DE-8987-4072-8841-DCB2144AC18C}.Debug|x86.Build.0 = Debug|Any CPU
168+
{42D043DE-8987-4072-8841-DCB2144AC18C}.Release|Any CPU.ActiveCfg = Release|Any CPU
169+
{42D043DE-8987-4072-8841-DCB2144AC18C}.Release|Any CPU.Build.0 = Release|Any CPU
170+
{42D043DE-8987-4072-8841-DCB2144AC18C}.Release|x64.ActiveCfg = Release|Any CPU
171+
{42D043DE-8987-4072-8841-DCB2144AC18C}.Release|x64.Build.0 = Release|Any CPU
172+
{42D043DE-8987-4072-8841-DCB2144AC18C}.Release|x86.ActiveCfg = Release|Any CPU
173+
{42D043DE-8987-4072-8841-DCB2144AC18C}.Release|x86.Build.0 = Release|Any CPU
146174
EndGlobalSection
147175
GlobalSection(SolutionProperties) = preSolution
148176
HideSolutionNode = FALSE
@@ -157,5 +185,7 @@ Global
157185
{8A28B784-F90B-469C-91BE-F96F63ACEA32} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
158186
{57518676-01F0-4D5B-A53B-7A06DBA9AA04} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
159187
{9B840598-3B03-457B-B1BE-9701BFD0D40A} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
188+
{14D91D30-8E5E-482A-940B-CC55F2DE80AA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
189+
{42D043DE-8987-4072-8841-DCB2144AC18C} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
160190
EndGlobalSection
161191
EndGlobal

docs/architecture/assemblies-and-roles.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@ Responsibilities:
8282
Use this when:
8383
- You want C# ownership of the server host process while reusing existing language services.
8484

85+
### `FScript.TypeProvider`
86+
Role:
87+
- F# compile-time type provider for exported FScript functions.
88+
89+
Responsibilities:
90+
- Parse and type-check `.fss` scripts during F# compilation.
91+
- Project `[<export>]` functions as strongly-typed static members.
92+
- Resolve compile-time/runtime extern providers.
93+
- Enforce runtime signature compatibility using compile-time fingerprints.
94+
95+
Use this when:
96+
- You want F# compile-time validation of script contracts.
97+
- You want strongly-typed invocation of exported script functions without hand-written wrappers.
98+
8599
## Typical composition
86100

87101
### CLI execution path
@@ -109,6 +123,7 @@ Use this when:
109123
- `FScript.Runtime` depends on `FScript.Language` types.
110124
- `FScript.CSharpInterop` depends on both `FScript.Language` and `FScript.Runtime`.
111125
- `FScript.LanguageServer` depends on `FScript.CSharpInterop`.
126+
- `FScript.TypeProvider` depends on `FScript.Language` and `FScript.Runtime`.
112127
- `FScript` depends on both `FScript.Language` and `FScript.Runtime`.
113128

114129
This keeps the language engine reusable while runtime capabilities remain host-configurable.

docs/specs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Normative behavior for the language, runtime surface, hosting model, and editor/
1717
## Hosting and security
1818

1919
- Embedding `FScript.Language`: [`embedding-fscript-language.md`](./embedding-fscript-language.md)
20+
- F# type provider for exported functions: [`fsharp-type-provider.md`](./fsharp-type-provider.md)
2021
- Sandbox and security: [`sandbox-and-security.md`](./sandbox-and-security.md)
2122

2223
## Editor/LSP behavior

docs/specs/embedding-fscript-language.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,3 +285,14 @@ open FScript.Language
285285
let typed = "[<export>] let run x = x" |> FScript.parse |> FScript.infer
286286
let descriptors = Descriptor.describeFunctions typed Map.empty
287287
```
288+
289+
### 6. Exported function signatures without evaluation
290+
Use `FScript.Runtime.ExportSignatures.fromTypedProgram` when a host needs exported function signatures from typed AST without executing script bodies.
291+
292+
```fsharp
293+
open FScript.Language
294+
open FScript.Runtime
295+
296+
let typed = "[<export>] let add (x: int) (y: int) = x + y" |> FScript.parse |> FScript.infer
297+
let signatures = ExportSignatures.fromTypedProgram typed
298+
```

docs/specs/fsharp-type-provider.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# F# Type Provider for FScript Exports
2+
3+
This specification defines the contract for `MagnusOpera.FScript.TypeProvider`.
4+
5+
## Purpose
6+
7+
- Compile-time parse and type-check `.fss` scripts.
8+
- Expose `[<export>]` functions as strongly-typed static methods in F#.
9+
- Allow runtime script replacement with strict signature compatibility checks.
10+
11+
## Provider entry point
12+
13+
- Namespace: `FScript.TypeProvider`
14+
- Type provider: `FScriptScriptProvider`
15+
16+
Static parameters:
17+
- `ScriptPath: string` (required)
18+
- `RootDirectory: string` (optional, defaults to script directory)
19+
- `ExternProviders: string` (optional, semicolon-separated assembly-qualified provider type names)
20+
21+
## Compile-time behavior
22+
23+
1. Resolve script path and root directory.
24+
2. Resolve externs using runtime defaults plus configured extern-provider types.
25+
3. Parse with includes from file and run type inference.
26+
4. Collect exported functions.
27+
5. Fail compilation on:
28+
- parse/type errors,
29+
- unsupported exported signature shapes.
30+
31+
## Exposed members
32+
33+
For each exported function, generate one static method with mapped .NET/F# types.
34+
35+
Provider-generated static members:
36+
- `SetRuntimeResolver : (unit -> RuntimeScriptOverride option) -> unit`
37+
- `ClearRuntimeResolver : unit -> unit`
38+
39+
`RuntimeScriptOverride` fields:
40+
- `RootDirectory: string`
41+
- `EntryFile: string`
42+
- `EntrySource: string`
43+
- `ResolveImportedSource: (string -> string option) option`
44+
45+
## Supported exported signature mapping (v1)
46+
47+
Supported:
48+
- `unit`
49+
- `int` -> `int64`
50+
- `float`
51+
- `bool`
52+
- `string`
53+
- `list<T>`
54+
- `option<T>`
55+
- tuples (arity `2..8`)
56+
- `map<string, T>` -> `Map<string, T>`
57+
58+
Rejected:
59+
- records
60+
- unions
61+
- named/custom types
62+
- function values in argument/return positions
63+
- unresolved type variables
64+
- non-string map keys
65+
66+
## Runtime compatibility policy
67+
68+
- Provider computes a compile-time fingerprint of all exported function signatures.
69+
- Every invocation loads script via compile-time path or active runtime resolver override.
70+
- Runtime exported signature fingerprint must exactly match compile-time fingerprint.
71+
- Mismatch fails invocation with an error before function execution.
72+
73+
## Runtime load source selection
74+
75+
- No resolver set: load compile-time script file.
76+
- Resolver set and returns `Some`: load `EntrySource` with `loadSourceWithIncludes` and optional import resolver.
77+
- Resolver set and returns `None`: fallback to compile-time script file.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
namespace FScript.Runtime
2+
3+
open FScript.Language
4+
5+
module ExportSignatures =
6+
type ExportedFunctionSignature =
7+
{ Name: string
8+
ParameterNames: string list
9+
ParameterTypes: Type list
10+
ReturnType: Type }
11+
12+
let private flattenFunctionType (t: Type) : Type list * Type =
13+
let rec loop (acc: Type list) (current: Type) =
14+
match current with
15+
| TFun (arg, ret) -> loop (arg :: acc) ret
16+
| _ -> List.rev acc, current
17+
loop [] t
18+
19+
let private flattenParameterNames (expr: Expr) : string list =
20+
let rec loop (acc: string list) (current: Expr) =
21+
match current with
22+
| ELambda (param, body, _) -> loop (param.Name :: acc) body
23+
| _ -> List.rev acc
24+
loop [] expr
25+
26+
let private fromLet (name: string) (expr: Expr) (exprType: Type) : ExportedFunctionSignature option =
27+
let parameterNames = flattenParameterNames expr
28+
let parameterTypes, returnType = flattenFunctionType exprType
29+
if parameterNames.IsEmpty || parameterTypes.IsEmpty then
30+
None
31+
elif parameterNames.Length <> parameterTypes.Length then
32+
raise (HostCommon.evalError $"Signature mismatch for function '{name}'")
33+
else
34+
Some
35+
{ Name = name
36+
ParameterNames = parameterNames
37+
ParameterTypes = parameterTypes
38+
ReturnType = returnType }
39+
40+
let fromTypedProgram (program: TypeInfer.TypedProgram) : Map<string, ExportedFunctionSignature> =
41+
program
42+
|> List.collect (function
43+
| TypeInfer.TSLet(name, expr, exprType, _, isExported, _) when isExported ->
44+
match fromLet name expr exprType with
45+
| Some signature -> [ signature.Name, signature ]
46+
| None -> []
47+
| TypeInfer.TSLetRecGroup(bindings, isExported, _) when isExported ->
48+
bindings
49+
|> List.choose (fun (name, expr, exprType, _) ->
50+
fromLet name expr exprType
51+
|> Option.map (fun signature -> signature.Name, signature))
52+
| _ -> [])
53+
|> Map.ofList

src/FScript.Runtime/FScript.Runtime.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
<Compile Include="JsonExterns.fs" />
3232
<Compile Include="XmlExterns.fs" />
3333
<Compile Include="Registry.fs" />
34+
<Compile Include="ExportSignatures.fs" />
3435
<Compile Include="ScriptHost.fs" />
3536
</ItemGroup>
3637

src/FScript.Runtime/ScriptHost.fs

Lines changed: 6 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -36,46 +36,14 @@ module ScriptHost =
3636
| TypeInfer.TSLetRecGroup(bindings, isExported, _) when isExported -> bindings |> List.map (fun (name, _, _, _) -> name)
3737
| _ -> [])
3838

39-
let private flattenFunctionType (t: Type) : Type list * Type =
40-
let rec loop (acc: Type list) (current: Type) =
41-
match current with
42-
| TFun (arg, ret) -> loop (arg :: acc) ret
43-
| _ -> List.rev acc, current
44-
loop [] t
45-
46-
let private flattenParameterNames (expr: Expr) : string list =
47-
let rec loop (acc: string list) (current: Expr) =
48-
match current with
49-
| ELambda (param, body, _) -> loop (param.Name :: acc) body
50-
| _ -> List.rev acc
51-
loop [] expr
52-
5339
let private collectFunctionSignatures (program: TypeInfer.TypedProgram) : Map<string, FunctionSignature> =
54-
let fromLet name expr exprType =
55-
let paramNames = flattenParameterNames expr
56-
let parameterTypes, returnType = flattenFunctionType exprType
57-
if paramNames.IsEmpty || parameterTypes.IsEmpty then
58-
None
59-
elif paramNames.Length <> parameterTypes.Length then
60-
raise (HostCommon.evalError $"Signature mismatch for function '{name}'")
61-
else
62-
Some (name,
63-
{ Name = name
64-
ParameterNames = paramNames
65-
ParameterTypes = parameterTypes
66-
ReturnType = returnType })
67-
6840
program
69-
|> List.collect (function
70-
| TypeInfer.TSLet(name, expr, exprType, _, isExported, _) when isExported ->
71-
match fromLet name expr exprType with
72-
| Some signature -> [ signature ]
73-
| None -> []
74-
| TypeInfer.TSLetRecGroup(bindings, isExported, _) when isExported ->
75-
bindings
76-
|> List.choose (fun (name, expr, exprType, _) -> fromLet name expr exprType)
77-
| _ -> [])
78-
|> Map.ofList
41+
|> ExportSignatures.fromTypedProgram
42+
|> Map.map (fun _ signature ->
43+
{ Name = signature.Name
44+
ParameterNames = signature.ParameterNames
45+
ParameterTypes = signature.ParameterTypes
46+
ReturnType = signature.ReturnType })
7947

8048
let private loadProgram (externs: ExternalFunction list) (program: Program) : LoadedScript =
8149
let typed = FScript.inferWithExterns externs program

0 commit comments

Comments
 (0)