Skip to content

Commit a6ec483

Browse files
Smaug123claude
andcommitted
Scope generated opens to the tagged type's namespace block
Every generator previously emitted every `open` from the whole input file into every generated namespace (via Whippet's AstHelper.extractOpens, which shadows the identically-named dead local helper). A relative open that is valid in one namespace block is not necessarily valid in another, so generated code for a type in a different block could fail to compile. extractOpensForNamespace collects opens only from the namespace blocks whose name is a segment-wise prefix of the tagged type's namespace: prefix rather than exact matching because CataGenerator's type discovery descends into nested modules (a type in module M of namespace N is reported as N.M, and block N's opens are lexically in scope around it). Same-named blocks share a resolution context, so unioning them is sound. The dead local extractOpens is replaced. ConsumePlugin/OpensLeakRegression.fs is a compile-time regression test: its bait block contains a relative `open RelativeOpenBait` which does not resolve in the victim namespace, so the pre-fix generator produces uncompilable output for it (verified against the pre-fix plugin). No checked-in generated code changes: no existing input relied on the leak. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent bb0b052 commit a6ec483

12 files changed

Lines changed: 321 additions & 9 deletions

ConsumePlugin/ConsumePlugin.fsproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@
100100
<Compile Include="GeneratedArgParserNegationTests.fs">
101101
<MyriadFile>ArgParserNegationTests.fs</MyriadFile>
102102
</Compile>
103+
<Compile Include="OpensLeakRegression.fs" />
104+
<Compile Include="GeneratedOpensLeakRegression.fs">
105+
<MyriadFile>OpensLeakRegression.fs</MyriadFile>
106+
</Compile>
103107
<!-- Not compiled, because by design they *don't* compile. That makes them very hard to test in an automated way! -->
104108
<None Include="ArgParserConflictTests.fs" />
105109
<!-- To run the conflict tests:
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
//------------------------------------------------------------------------------
2+
// This code was generated by myriad.
3+
// Changes to this file will be lost when the code is regenerated.
4+
//------------------------------------------------------------------------------
5+
6+
7+
8+
9+
10+
11+
12+
namespace ConsumePlugin.OpensLeakVictim
13+
14+
open WoofWare.Myriad.Plugins
15+
16+
/// Methods to parse arguments for the type LeakArgs
17+
[<RequireQualifiedAccess ; CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
18+
module LeakArgs =
19+
type private ParseState_LeakArgs =
20+
/// Ready to consume a key or positional arg
21+
| AwaitingKey
22+
/// Waiting to receive a value for the key we've already consumed
23+
| AwaitingValue of key : string
24+
25+
let parse' (getEnvironmentVariable : string -> string option) (args : string list) : LeakArgs =
26+
let ArgParser_errors = ResizeArray ()
27+
28+
let helpText () =
29+
[ (sprintf "%s int32%s%s" (sprintf "--%s" "foo") "" "") ] |> String.concat "\n"
30+
31+
let parser_LeftoverArgs : string ResizeArray = ResizeArray ()
32+
let mutable arg_0 : int option = None
33+
34+
/// Processes the key-value pair, returning Error if no key was matched.
35+
/// If the key is an arg which can have arity 1, but throws when consuming that arg, we return Error(<the message>).
36+
/// This can nevertheless be a successful parse, e.g. when the key may have arity 0.
37+
let processKeyValue (key : string) (value : string) : Result<unit, string option> =
38+
if System.String.Equals (key, sprintf "--%s" "foo", System.StringComparison.OrdinalIgnoreCase) then
39+
match arg_0 with
40+
| Some x ->
41+
sprintf
42+
"Argument '%s' was supplied multiple times: %s and %s"
43+
(sprintf "--%s" "foo")
44+
(x.ToString ())
45+
(value.ToString ())
46+
|> ArgParser_errors.Add
47+
48+
Ok ()
49+
| None ->
50+
try
51+
arg_0 <- value |> (fun x -> System.Int32.Parse x) |> Some
52+
Ok ()
53+
with _ as exc ->
54+
exc.Message |> Some |> Error
55+
else
56+
Error None
57+
58+
/// Returns false if we didn't set a value.
59+
let setFlagValue (key : string) : bool = false
60+
61+
let rec go (state : ParseState_LeakArgs) (args : string list) =
62+
match args with
63+
| [] ->
64+
match state with
65+
| ParseState_LeakArgs.AwaitingKey -> ()
66+
| ParseState_LeakArgs.AwaitingValue key ->
67+
if setFlagValue key then
68+
()
69+
else
70+
sprintf
71+
"Trailing argument %s had no value. Use a double-dash to separate positional args from key-value args."
72+
key
73+
|> ArgParser_errors.Add
74+
| "--" :: rest ->
75+
match state with
76+
| ParseState_LeakArgs.AwaitingKey -> ()
77+
| ParseState_LeakArgs.AwaitingValue key ->
78+
if setFlagValue key then
79+
()
80+
else
81+
sprintf
82+
"Trailing argument %s had no value. Use a double-dash to separate positional args from key-value args."
83+
key
84+
|> ArgParser_errors.Add
85+
86+
parser_LeftoverArgs.AddRange (rest |> Seq.map (fun x -> x))
87+
| arg :: args ->
88+
match state with
89+
| ParseState_LeakArgs.AwaitingKey ->
90+
if arg.StartsWith ("--", System.StringComparison.Ordinal) then
91+
if arg = "--help" then
92+
helpText () |> failwithf "Help text requested.\n%s"
93+
else
94+
let equals = arg.IndexOf (char 61)
95+
96+
if equals < 0 then
97+
args |> go (ParseState_LeakArgs.AwaitingValue arg)
98+
else
99+
let key = arg.[0 .. equals - 1]
100+
let value = arg.[equals + 1 ..]
101+
102+
match processKeyValue key value with
103+
| Ok () -> go ParseState_LeakArgs.AwaitingKey args
104+
| Error x ->
105+
match x with
106+
| None ->
107+
failwithf "Unable to process argument %s as key %s and value %s" arg key value
108+
| Some msg ->
109+
sprintf "%s (at arg %s)" msg arg |> ArgParser_errors.Add
110+
go ParseState_LeakArgs.AwaitingKey args
111+
else
112+
arg |> (fun x -> x) |> parser_LeftoverArgs.Add
113+
go ParseState_LeakArgs.AwaitingKey args
114+
| ParseState_LeakArgs.AwaitingValue key ->
115+
match processKeyValue key arg with
116+
| Ok () -> go ParseState_LeakArgs.AwaitingKey args
117+
| Error exc ->
118+
if setFlagValue key then
119+
go ParseState_LeakArgs.AwaitingKey (arg :: args)
120+
else
121+
match exc with
122+
| None ->
123+
failwithf "Unable to process supplied arg %s. Help text follows.\n%s" key (helpText ())
124+
| Some msg -> msg |> ArgParser_errors.Add
125+
126+
go ParseState_LeakArgs.AwaitingKey args
127+
128+
let parser_LeftoverArgs =
129+
if 0 = parser_LeftoverArgs.Count then
130+
()
131+
else
132+
parser_LeftoverArgs
133+
|> String.concat " "
134+
|> sprintf "There were leftover args: %s"
135+
|> ArgParser_errors.Add
136+
137+
Unchecked.defaultof<_>
138+
139+
let arg_0 =
140+
match arg_0 with
141+
| None ->
142+
sprintf "Required argument '%s' received no value" (sprintf "--%s" "foo")
143+
|> ArgParser_errors.Add
144+
145+
Unchecked.defaultof<_>
146+
| Some x -> x
147+
148+
if 0 = ArgParser_errors.Count then
149+
{
150+
Foo = arg_0
151+
}
152+
else
153+
ArgParser_errors |> String.concat "\n" |> failwithf "Errors during parse!\n%s"
154+
155+
let parse (args : string list) : LeakArgs =
156+
parse' (System.Environment.GetEnvironmentVariable >> Option.ofObj) args
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Regression test for the opens leak: `open` declarations from one namespace block of an input
2+
// file must not appear in generated code for a *different* namespace block. The bait below is a
3+
// relative `open RelativeOpenBait`, which resolves only from within ConsumePlugin.OpensLeak; if
4+
// it leaked into the generated module for ConsumePlugin.OpensLeakVictim, this project would fail
5+
// to compile.
6+
namespace ConsumePlugin.OpensLeak
7+
8+
module internal RelativeOpenBait =
9+
let internal bait : int = 1
10+
11+
namespace ConsumePlugin.OpensLeak
12+
13+
open RelativeOpenBait
14+
15+
type UsesTheBait =
16+
{
17+
Ignored : int
18+
}
19+
20+
static member internal Use () : int = bait
21+
22+
namespace ConsumePlugin.OpensLeakVictim
23+
24+
open WoofWare.Myriad.Plugins
25+
26+
[<ArgParser>]
27+
type LeakArgs =
28+
{
29+
Foo : int
30+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
namespace WoofWare.Myriad.Plugins.Test
2+
3+
open NUnit.Framework
4+
open FsUnitTyped
5+
open Fantomas.FCS.Syntax
6+
open WoofWare.Whippet.Fantomas
7+
open WoofWare.Myriad.Plugins
8+
9+
[<TestFixture>]
10+
module TestAstHelper =
11+
12+
/// Render an open target as its dotted path, for easy comparison.
13+
let private renderOpen (target : SynOpenDeclTarget) : string =
14+
match target with
15+
| SynOpenDeclTarget.ModuleOrNamespace (SynLongIdent.SynLongIdent (ident, _, _), _) ->
16+
ident |> List.map _.idText |> String.concat "."
17+
| SynOpenDeclTarget.Type _ -> failwith "open type not expected in these tests"
18+
19+
let private opensFor (ns : string) (source : string) : string list =
20+
let ast = Ast.parse source
21+
let nsIdent = ns.Split '.' |> Seq.map Ident.create |> List.ofSeq
22+
AstHelper.extractOpensForNamespace nsIdent ast |> List.map renderOpen
23+
24+
let private source : string =
25+
"""namespace First
26+
27+
open System.IO
28+
29+
module Helpers =
30+
let x = 1
31+
32+
namespace First
33+
34+
open Helpers
35+
36+
namespace First.Unrelated
37+
38+
open System.Text
39+
40+
namespace Second
41+
42+
open System.Collections.Generic
43+
"""
44+
45+
[<Test>]
46+
let ``An exactly-matching block contributes its opens verbatim`` () =
47+
// Both blocks named First match; neither gets a context-restoring prefix open.
48+
opensFor "First" source |> shouldEqual [ "System.IO" ; "Helpers" ]
49+
50+
[<Test>]
51+
let ``Opens from unrelated blocks do not leak`` () =
52+
opensFor "Second" source |> shouldEqual [ "System.Collections.Generic" ]
53+
54+
[<Test>]
55+
let ``An ancestor block's opens are preceded by an absolute open of that block`` () =
56+
// A type discovered inside `module M` of block First is reported with namespace First.M;
57+
// block First's opens apply, but its relative opens (open Helpers) only resolve once the
58+
// ancestor block is itself opened absolutely.
59+
opensFor "First.M" source
60+
|> shouldEqual [ "First" ; "System.IO" ; "First" ; "Helpers" ]
61+
62+
[<Test>]
63+
let ``Sibling namespaces sharing a name prefix segment do not match non-segment-wise`` () =
64+
// "First.Unrelated" is not an ancestor of "First.UnrelatedM"; only segment-wise prefixes
65+
// count, so "First.Unrel" style string prefixes must not match either.
66+
opensFor "First.UnrelatedM" source
67+
|> shouldEqual [ "First" ; "System.IO" ; "First" ; "Helpers" ]
68+
69+
[<Test>]
70+
let ``A block with no opens contributes nothing, not even a context open`` () =
71+
opensFor "Second.M.Deep" "namespace Second.M\n\ntype T = { F : int }\n"
72+
|> shouldEqual []

WoofWare.Myriad.Plugins.Test/WoofWare.Myriad.Plugins.Test.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
<EmbeddedResource Include="TestSwagger\uspto.json" />
6262
<EmbeddedResource Include="TestSwagger\webhook-example.json" />
6363
<Compile Include="TestRemoveOptions.fs" />
64+
<Compile Include="TestAstHelper.fs" />
6465
<Compile Include="TestSurface.fs" />
6566
<None Include="../.github/workflows/dotnet.yaml" />
6667
</ItemGroup>

WoofWare.Myriad.Plugins/ArgParserGenerator.fs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2148,6 +2148,7 @@ type ArgParserGenerator () =
21482148
let modules =
21492149
namespaceAndTypes
21502150
|> List.map (fun (ns, taggedType, unions, records) ->
2151+
let opens = AstHelper.extractOpensForNamespace ns ast
21512152
ArgParserGenerator.createModule opens ns taggedType unions records
21522153
)
21532154

WoofWare.Myriad.Plugins/AstHelper.fs

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,57 @@ module internal AstHelper =
5757
| _ -> None
5858
)
5959

60-
let extractOpens (ast : ParsedInput) : SynOpenDeclTarget list =
60+
/// Extract the `open` declarations which are in scope for generated code emitted into
61+
/// namespace `ns`: the opens of every top-level namespace/module block of the file whose name
62+
/// is a segment-wise prefix of `ns`.
63+
///
64+
/// Prefix (rather than exact) matching is needed because type discovery descends into nested
65+
/// modules: a type inside `module M` of `namespace N` is reported with namespace `N.M`, and
66+
/// the opens of block `N` are lexically in scope around it. Opens of blocks whose name is not
67+
/// a prefix must not leak in: a relative `open` which is valid in one namespace block can be
68+
/// invalid (or resolve to something else) in another. Two blocks with the same name share a
69+
/// resolution context, so it is sound to union their opens.
70+
///
71+
/// A *strict*-prefix ancestor block's opens may themselves be relative to that block (e.g.
72+
/// `open Helpers` for a `module Helpers` in the same block), and relative opens do not
73+
/// resolve across blocks: `namespace N.M` cannot `open Bait` to reach `N.Bait`. So before an
74+
/// ancestor block's opens we emit an absolute open of the ancestor block itself, which
75+
/// re-establishes its resolution context (F# resolves later opens through earlier ones).
76+
///
77+
/// (Note: this deliberately does not share a name with WoofWare.Whippet.Fantomas's
78+
/// `AstHelper.extractOpens`, which shadows this module's members at generator call sites and
79+
/// extracts opens from *every* block in the file.)
80+
let extractOpensForNamespace (ns : LongIdent) (ast : ParsedInput) : SynOpenDeclTarget list =
81+
let nsName = ns |> List.map _.idText
82+
83+
let isPrefixOfNs (blockName : string list) : bool =
84+
List.length blockName <= List.length nsName
85+
&& List.forall2
86+
(fun (a : string) (b : string) -> System.String.Equals (a, b, System.StringComparison.Ordinal))
87+
blockName
88+
(List.truncate (List.length blockName) nsName)
89+
6190
match ast with
6291
| ParsedInput.ImplFile (ParsedImplFileInput (_, _, _, _, _, modules, _, _, _)) ->
6392
modules
64-
|> List.collect (fun (SynModuleOrNamespace (_, _, _, decls, _, _, _, _, _)) -> extractOpensFromDecl decls)
93+
|> List.collect (fun (SynModuleOrNamespace (longId, _, _, decls, _, _, _, _, _)) ->
94+
let blockName = longId |> List.map _.idText
95+
96+
if not (isPrefixOfNs blockName) then
97+
[]
98+
else
99+
100+
match extractOpensFromDecl decls with
101+
| [] -> []
102+
| opens ->
103+
if List.length blockName = List.length nsName then
104+
// The block *is* the target namespace: its opens are valid verbatim.
105+
opens
106+
else
107+
// Strict-prefix ancestor: re-establish its resolution context first.
108+
SynOpenDeclTarget.ModuleOrNamespace (SynLongIdent.create longId, range0)
109+
:: opens
110+
)
65111
| _ -> []
66112

67113
let rec convertSigParam (ty : SynType) : ParameterInfo * bool =

WoofWare.Myriad.Plugins/CapturingInterfaceMockGenerator.fs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -735,11 +735,10 @@ type CapturingInterfaceMockGenerator () =
735735
| ty -> Some (ns, ty)
736736
)
737737

738-
let opens = AstHelper.extractOpens ast
739-
740738
let modules =
741739
namespaceAndInterfaces
742740
|> List.collect (fun (ns, records) ->
741+
let opens = AstHelper.extractOpensForNamespace ns ast
743742
records |> List.map (CapturingInterfaceMockGenerator.createRecord ns opens)
744743
)
745744

WoofWare.Myriad.Plugins/CataGenerator.fs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1266,6 +1266,7 @@ type CreateCatamorphismGenerator () =
12661266
let modules =
12671267
namespaceAndTypes
12681268
|> List.map (fun (ns, taggedType, unions, records) ->
1269+
let opens = AstHelper.extractOpensForNamespace ns ast
12691270
CataGenerator.createModule opens ns taggedType unions records
12701271
)
12711272

WoofWare.Myriad.Plugins/HttpClientGenerator.fs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1294,6 +1294,9 @@ type HttpClientGenerator () =
12941294

12951295
let modules =
12961296
namespaceAndTypes
1297-
|> List.collect (fun (ns, types) -> types |> List.map (HttpClientGenerator.createModule opens ns))
1297+
|> List.collect (fun (ns, types) ->
1298+
let opens = AstHelper.extractOpensForNamespace ns ast
1299+
types |> List.map (HttpClientGenerator.createModule opens ns)
1300+
)
12981301

12991302
Output.Ast modules

0 commit comments

Comments
 (0)