@voxgig/sdkgen 3.6.0 → 3.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/dist/cmp/AgentGuideTop.js +17 -0
  3. package/dist/cmp/AgentGuideTop.js.map +1 -1
  4. package/dist/cmp/Readme.js +2 -0
  5. package/dist/cmp/Readme.js.map +1 -1
  6. package/dist/cmp/ReadmeStation.d.ts +2 -0
  7. package/dist/cmp/ReadmeStation.js +57 -0
  8. package/dist/cmp/ReadmeStation.js.map +1 -0
  9. package/dist/cmp/ReadmeTop.js +11 -4
  10. package/dist/cmp/ReadmeTop.js.map +1 -1
  11. package/dist/helpers/naming.d.ts +3 -1
  12. package/dist/helpers/naming.js +39 -2
  13. package/dist/helpers/naming.js.map +1 -1
  14. package/dist/sdkgen.d.ts +2 -2
  15. package/dist/sdkgen.js +4 -2
  16. package/dist/sdkgen.js.map +1 -1
  17. package/dist/tsconfig.tsbuildinfo +1 -1
  18. package/dist/utility.d.ts +1 -1
  19. package/dist/utility.js +16 -2
  20. package/dist/utility.js.map +1 -1
  21. package/package.json +1 -1
  22. package/project/.sdk/src/cmp/c/Config_c.ts +34 -13
  23. package/project/.sdk/src/cmp/cpp/Config_cpp.ts +9 -46
  24. package/project/.sdk/src/cmp/csharp/Config_csharp.ts +7 -1
  25. package/project/.sdk/src/cmp/dart/Config_dart.ts +14 -2
  26. package/project/.sdk/src/cmp/dart/fragment/Config.data.fragment.dart +5 -0
  27. package/project/.sdk/src/cmp/dart/fragment/Config.fragment.dart +6 -0
  28. package/project/.sdk/src/cmp/dart/fragment/Main.fragment.dart +13 -4
  29. package/project/.sdk/src/cmp/elixir/Config_elixir.ts +12 -2
  30. package/project/.sdk/src/cmp/elixir/Package_elixir.ts +41 -2
  31. package/project/.sdk/src/cmp/go/Config_go.ts +8 -1
  32. package/project/.sdk/src/cmp/go/ReadmeIntro_go.ts +19 -4
  33. package/project/.sdk/src/cmp/java/Config_java.ts +8 -1
  34. package/project/.sdk/src/cmp/js/Config_js.ts +9 -1
  35. package/project/.sdk/src/cmp/js/fragment/Config.data.fragment.js +7 -0
  36. package/project/.sdk/src/cmp/js/fragment/Config.fragment.js +8 -0
  37. package/project/.sdk/src/cmp/js/fragment/Main.fragment.js +33 -5
  38. package/project/.sdk/src/cmp/kotlin/Config_kotlin.ts +8 -1
  39. package/project/.sdk/src/cmp/lua/Config_lua.ts +11 -2
  40. package/project/.sdk/src/cmp/lua/Package_lua.ts +24 -1
  41. package/project/.sdk/src/cmp/perl/Config_perl.ts +8 -60
  42. package/project/.sdk/src/cmp/php/Config_php.ts +11 -2
  43. package/project/.sdk/src/cmp/php/Main_php.ts +25 -0
  44. package/project/.sdk/src/cmp/php/fragment/Main.fragment.php +23 -6
  45. package/project/.sdk/src/cmp/py/Config_py.ts +11 -2
  46. package/project/.sdk/src/cmp/py/Main_py.ts +17 -7
  47. package/project/.sdk/src/cmp/py/fragment/Main.fragment.py +23 -6
  48. package/project/.sdk/src/cmp/rb/Config_rb.ts +7 -1
  49. package/project/.sdk/src/cmp/rust/Config_rust.ts +5 -2
  50. package/project/.sdk/src/cmp/swift/Config_swift.ts +8 -54
  51. package/project/.sdk/src/cmp/swift/Package_swift.ts +52 -10
  52. package/project/.sdk/src/cmp/ts/Config_ts.ts +9 -1
  53. package/project/.sdk/src/cmp/ts/EntityTypes_ts.ts +5 -2
  54. package/project/.sdk/src/cmp/ts/Entity_ts.ts +8 -2
  55. package/project/.sdk/src/cmp/ts/Package_ts.ts +3 -1
  56. package/project/.sdk/src/cmp/ts/ReadmeIntro_ts.ts +19 -4
  57. package/project/.sdk/src/cmp/ts/fragment/Config.data.fragment.ts +7 -0
  58. package/project/.sdk/src/cmp/ts/fragment/Config.fragment.ts +8 -0
  59. package/project/.sdk/src/cmp/ts/fragment/Entity.fragment.ts +1 -1
  60. package/project/.sdk/src/cmp/ts/fragment/Main.fragment.ts +12 -4
  61. package/project/.sdk/tm/c/Makefile +6 -1
  62. package/project/.sdk/tm/c/utility/make_options.c +12 -1
  63. package/project/.sdk/tm/cpp/utility/pipeline.hpp +9 -1
  64. package/project/.sdk/tm/csharp/utility/Fetcher.cs +23 -5
  65. package/project/.sdk/tm/csharp/utility/MakeOptions.cs +13 -0
  66. package/project/.sdk/tm/dart/lib/utility/FetcherUtility.dart +9 -0
  67. package/project/.sdk/tm/dart/lib/utility/MakeOptionsUtility.dart +17 -0
  68. package/project/.sdk/tm/elixir/lib/projectname/utility.ex +39 -3
  69. package/project/.sdk/tm/elixir/test/feature_test.exs +19 -0
  70. package/project/.sdk/tm/elixir/test/primary_utility_test.exs +72 -0
  71. package/project/.sdk/tm/go/test/primary_utility_test.go +42 -20
  72. package/project/.sdk/tm/go/test/runner_test.go +1 -1
  73. package/project/.sdk/tm/go/utility/fetcher.go +14 -0
  74. package/project/.sdk/tm/go/utility/make_options.go +31 -4
  75. package/project/.sdk/tm/java/utility/Fetcher.java +11 -1
  76. package/project/.sdk/tm/java/utility/MakeOptions.java +11 -0
  77. package/project/.sdk/tm/js/src/utility/MakeOptionsUtility.js +20 -2
  78. package/project/.sdk/tm/kotlin/utility/Fetcher.kt +10 -1
  79. package/project/.sdk/tm/kotlin/utility/MakeOptions.kt +11 -0
  80. package/project/.sdk/tm/lean/test/TPrimaryUtility.lean +13 -2
  81. package/project/.sdk/tm/lua/utility/make_options.lua +44 -0
  82. package/project/.sdk/tm/perl/utility/fetcher.pm +10 -1
  83. package/project/.sdk/tm/perl/utility/make_options.pm +36 -0
  84. package/project/.sdk/tm/php/test/PipelineTest.php +20 -0
  85. package/project/.sdk/tm/php/utility/Fetcher.php +12 -1
  86. package/project/.sdk/tm/php/utility/MakeOptions.php +58 -0
  87. package/project/.sdk/tm/py/pkg/utility/fetcher.py +13 -1
  88. package/project/.sdk/tm/py/pkg/utility/make_options.py +23 -0
  89. package/project/.sdk/tm/rb/utility/make_options.rb +18 -0
  90. package/project/.sdk/tm/rust/tests/common/mod.rs +1 -1
  91. package/project/.sdk/tm/rust/tests/feature_test.rs +27 -0
  92. package/project/.sdk/tm/rust/utility/fetcher.rs +8 -0
  93. package/project/.sdk/tm/rust/utility/make_options.rs +15 -0
  94. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/Fetcher.swift +24 -1
  95. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/MakeOptions.swift +12 -0
  96. package/project/.sdk/tm/ts/src/utility/MakeOptionsUtility.ts +20 -2
  97. package/project/.sdk/tm/ts/test/exists.test.ts +14 -3
  98. package/project/.sdk/tm/ts/test/utility/Corpus.test.ts +85 -0
  99. package/project/.sdk/tm/ts/test/utility/PrimaryUtility.test.ts +10 -17
  100. package/project/sdkgen-package.json +1 -1
  101. package/src/cmp/AgentGuideTop.ts +18 -0
  102. package/src/cmp/Readme.ts +2 -0
  103. package/src/cmp/ReadmeStation.ts +69 -0
  104. package/src/cmp/ReadmeTop.ts +12 -3
  105. package/src/helpers/naming.ts +45 -2
  106. package/src/sdkgen.ts +3 -1
  107. package/src/utility.ts +17 -2
@@ -217,17 +217,44 @@ func makeOptionsUtil(ctx *core.Context) map[string]any {
217
217
  hasTest = true
218
218
  }
219
219
  }
220
+ ordered := make([]string, 0, len(names))
220
221
  if hasTest {
221
- featureorder = append(featureorder, "test")
222
+ ordered = append(ordered, "test")
222
223
  for _, n := range names {
223
224
  if n != "test" {
224
- featureorder = append(featureorder, n)
225
+ ordered = append(ordered, n)
225
226
  }
226
227
  }
227
228
  } else {
228
- for _, n := range names {
229
- featureorder = append(featureorder, n)
229
+ ordered = append(ordered, names...)
230
+ }
231
+ // Station special case, mirroring test's: its transport wrap must
232
+ // sit immediately outside the base transport (inside retry/cache/
233
+ // netsim), so map-form activation hoists it to just after test -
234
+ // or first, when no test entry exists. Without this the sorted
235
+ // default would init station last and wrap OUTSIDE the recording
236
+ // features, turning its wire-truth events into fiction.
237
+ si := -1
238
+ for i, n := range ordered {
239
+ if n == "station" {
240
+ si = i
241
+ break
242
+ }
243
+ }
244
+ if si >= 0 {
245
+ ordered = append(ordered[:si], ordered[si+1:]...)
246
+ ti := 0
247
+ for i, n := range ordered {
248
+ if n == "test" {
249
+ ti = i + 1
250
+ break
251
+ }
230
252
  }
253
+ ordered = append(ordered[:ti],
254
+ append([]string{"station"}, ordered[ti:]...)...)
255
+ }
256
+ for _, n := range ordered {
257
+ featureorder = append(featureorder, n)
231
258
  }
232
259
  }
233
260
 
@@ -59,10 +59,20 @@ final class Fetcher {
59
59
  reqb.setHeader("User-Agent", "Mozilla/5.0 (compatible; ProjectNameSDK/1.0)");
60
60
  }
61
61
 
62
+ // Honour a redirect annotation on the fetch definition (set by the
63
+ // station feature's middleware under a hosts policy): "manual" returns
64
+ // a 3xx like any other response instead of following it — the ts
65
+ // donor's fetch honours the same key natively, and an automatic follow
66
+ // would carry injected credentials to a host no policy approved.
67
+ HttpClient.Redirect redirectPolicy =
68
+ "manual".equals(fetchdef.get("redirect"))
69
+ ? HttpClient.Redirect.NEVER
70
+ : HttpClient.Redirect.NORMAL;
71
+
62
72
  // Honour a proxy annotation on the fetch definition (set by the proxy
63
73
  // feature): route the request through a proxied HttpClient.
64
74
  HttpClient.Builder clientb = HttpClient.newBuilder()
65
- .followRedirects(HttpClient.Redirect.NORMAL);
75
+ .followRedirects(redirectPolicy);
66
76
  Object proxy = fetchdef.get("proxy");
67
77
  if (proxy instanceof String && !"".equals(proxy)) {
68
78
  try {
@@ -156,6 +156,17 @@ final class MakeOptions {
156
156
  else {
157
157
  featureorder.addAll(names);
158
158
  }
159
+ // Station special case, mirroring test's: its transport wrap must
160
+ // sit immediately outside the base transport (inside retry/cache/
161
+ // netsim), so map-form activation hoists it to just after test -
162
+ // or first, when no test entry exists. Without this the sorted
163
+ // default would init station last and wrap OUTSIDE the recording
164
+ // features, turning its wire-truth events into fiction.
165
+ int si = featureorder.indexOf("station");
166
+ if (0 <= si) {
167
+ featureorder.remove(si);
168
+ featureorder.add(featureorder.indexOf("test") + 1, "station");
169
+ }
159
170
  }
160
171
 
161
172
  Map<String, Object> derived = new LinkedHashMap<>();
@@ -68,6 +68,12 @@ function makeOptions(ctx) {
68
68
  }
69
69
  },
70
70
  utility: {},
71
+ // Feature INSTANCES supplied at construction (the station adopt
72
+ // path): consumed by the constructor's featureAdd loop, so they are
73
+ // class instances, not data - `$ANY` accepts them verbatim. Without
74
+ // this entry the seam is dead: the constructor reads
75
+ // options.extend, but validate rejected the key.
76
+ extend: '`$ANY`',
71
77
  system: {
72
78
  fetch: undefined
73
79
  },
@@ -131,10 +137,22 @@ function makeOptions(ctx) {
131
137
  // otherwise order the map test-first, then the remaining names sorted, so
132
138
  // the outcome is deterministic and `test` is always the base transport.
133
139
  if (0 === featureorder.length) {
134
- const names = Object.keys(opts.feature || {}).sort()
135
- featureorder = names.indexOf('test') < 0
140
+ let names = Object.keys(opts.feature || {}).sort()
141
+ names = names.indexOf('test') < 0
136
142
  ? names
137
143
  : ['test'].concat(names.filter((n) => 'test' !== n))
144
+ // Station special case, mirroring test's: its transport wrap must
145
+ // sit immediately outside the base transport (inside retry/cache/
146
+ // netsim), so map-form activation hoists it to just after test -
147
+ // or first, when no test entry exists. Without this the sorted
148
+ // default would init station last and wrap OUTSIDE the recording
149
+ // features, turning its wire-truth events into fiction.
150
+ const si = names.indexOf('station')
151
+ if (0 <= si) {
152
+ names.splice(si, 1)
153
+ names.splice(names.indexOf('test') + 1, 0, 'station')
154
+ }
155
+ featureorder = names
138
156
  }
139
157
 
140
158
  opts.__derived__ = {
@@ -50,9 +50,18 @@ fun defaultHttpFetch(fullurl: String, fetchdef: MutableMap<String, Any?>): Mutab
50
50
  reqb.setHeader("User-Agent", "Mozilla/5.0 (compatible; ProjectNameSDK/1.0)")
51
51
  }
52
52
 
53
+ // Honour a redirect annotation on the fetch definition (set by the
54
+ // station feature's middleware under a hosts policy): "manual" returns
55
+ // a 3xx like any other response instead of following it — the ts
56
+ // donor's fetch honours the same key natively, and an automatic follow
57
+ // would carry injected credentials to a host no policy approved.
58
+ val redirectPolicy =
59
+ if ("manual" == fetchdef["redirect"]) HttpClient.Redirect.NEVER
60
+ else HttpClient.Redirect.NORMAL
61
+
53
62
  // Honour a proxy annotation on the fetch definition (set by the proxy
54
63
  // feature): route the request through a proxied HttpClient.
55
- val clientb = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL)
64
+ val clientb = HttpClient.newBuilder().followRedirects(redirectPolicy)
56
65
  val proxy = fetchdef["proxy"]
57
66
  if (proxy is String && "" != proxy) {
58
67
  try {
@@ -141,6 +141,17 @@ fun makeOptions(ctx: Context): MutableMap<String, Any?> {
141
141
  } else {
142
142
  featureorder.addAll(names)
143
143
  }
144
+ // Station special case, mirroring test's: its transport wrap must
145
+ // sit immediately outside the base transport (inside retry/cache/
146
+ // netsim), so map-form activation hoists it to just after test -
147
+ // or first, when no test entry exists. Without this the sorted
148
+ // default would init station last and wrap OUTSIDE the recording
149
+ // features, turning its wire-truth events into fiction.
150
+ val si = featureorder.indexOf("station")
151
+ if (0 <= si) {
152
+ featureorder.removeAt(si)
153
+ featureorder.add(featureorder.indexOf("test") + 1, "station")
154
+ }
144
155
  }
145
156
 
146
157
  val derived = linkedMapOf<String, Any?>()
@@ -316,8 +316,19 @@ def main : IO UInt32 := do
316
316
  runset "makeResult" (← getSpec primary #["makeResult", "basic"]) fun entry => do
317
317
  pure ((← SdkUtility.makeResult (← entryCtx entry opts config)), none)
318
318
 
319
- runset "makePoint" (← getSpec primary #["makePoint", "basic"]) fun entry => do
320
- pure ((← SdkUtility.makePoint (← entryCtx entry opts config)), none)
319
+ -- makePoint is NOT driven here, and this is deliberate.
320
+ --
321
+ -- The section used to be empty, so running it asserted nothing. It now
322
+ -- carries seven cases, and every one supplies its own `options` (for
323
+ -- allow.op) and `config` (for the operation's points) — but `entryCtx`
324
+ -- OVERWRITES both with the SDK's, so the cases would not run as authored.
325
+ -- Wiring it up needs entryCtx to prefer a fixture-supplied options/config,
326
+ -- which changes every section here and could not be compiled or run when
327
+ -- this change was made.
328
+ --
329
+ -- Left out rather than left in: an entry that runs with the wrong context
330
+ -- is worse than one that does not run, and no coverage is lost — this
331
+ -- section asserted nothing before either.
321
332
 
322
333
  runset "makeFetchDef" (← getSpec primary #["makeFetchDef", "basic"]) fun entry => do
323
334
  pure ((← SdkUtility.makeFetchDef (← entryCtx entry opts config)), none)
@@ -82,6 +82,12 @@ local function make_options_util(ctx)
82
82
  },
83
83
  },
84
84
  utility = {},
85
+ -- Feature INSTANCES supplied at construction (the station adopt
86
+ -- path): consumed by the constructor's feature_add loop, so they are
87
+ -- class instances, not data -- `$ANY` accepts them verbatim. Without
88
+ -- this entry the seam is dead: the constructor reads
89
+ -- options.extend, but validate rejected the key.
90
+ extend = "`$ANY`",
85
91
  system = {},
86
92
  test = {
87
93
  active = false,
@@ -104,6 +110,15 @@ local function make_options_util(ctx)
104
110
  -- Preserve system.fetch before merge/validate.
105
111
  local sys_fetch = vs.getpath(opts, "system.fetch")
106
112
 
113
+ -- Preserve extend feature INSTANCES before merge/validate, by ORIGINAL
114
+ -- reference (the ts makeOptions keeps the caller's instances too):
115
+ -- merge/validate are data-oriented and would mangle instance tables
116
+ -- carrying functions -- the same reason system.fetch is preserved.
117
+ local extend = nil
118
+ if type(options) == "table" and type(options["extend"]) == "table" then
119
+ extend = options["extend"]
120
+ end
121
+
107
122
  -- Clone the config side before merging: `config` is a per-Lua-state
108
123
  -- singleton (see config_shared), and merge would otherwise use its nested
109
124
  -- tables as merge TARGETS — one instance's options (server, headers, ...)
@@ -156,6 +171,11 @@ local function make_options_util(ctx)
156
171
  end
157
172
  end
158
173
 
174
+ -- Restore extend feature instances.
175
+ if extend ~= nil then
176
+ opts["extend"] = extend
177
+ end
178
+
159
179
  -- Derived clean config.
160
180
  local clean_keys = "key,token,id"
161
181
  local ck = vs.getpath(opts, "clean.keys")
@@ -202,6 +222,30 @@ local function make_options_util(ctx)
202
222
  else
203
223
  featureorder = names
204
224
  end
225
+ -- Station special case, mirroring test's: its transport wrap must
226
+ -- sit immediately outside the base transport (inside retry/cache/
227
+ -- netsim), so map-form activation hoists it to just after test -
228
+ -- or first, when no test entry exists. Without this the sorted
229
+ -- default would init station last and wrap OUTSIDE the recording
230
+ -- features, turning its wire-truth events into fiction.
231
+ local si = nil
232
+ for i, n in ipairs(featureorder) do
233
+ if n == "station" then
234
+ si = i
235
+ break
236
+ end
237
+ end
238
+ if si ~= nil then
239
+ table.remove(featureorder, si)
240
+ local ti = 0
241
+ for i, n in ipairs(featureorder) do
242
+ if n == "test" then
243
+ ti = i
244
+ break
245
+ end
246
+ end
247
+ table.insert(featureorder, ti + 1, "station")
248
+ end
205
249
  end
206
250
 
207
251
  local derived = { clean = {} }
@@ -39,7 +39,16 @@ our $DefaultHttpFetch = sub {
39
39
  my $opts = { headers => \%hdrs };
40
40
  $opts->{content} = "$body" if defined $body && !ref $body;
41
41
 
42
- my $res = eval { HTTP::Tiny->new->request($method, $fullurl, $opts) };
42
+ # fetchdef.redirect 'manual' (the ts fetch vocabulary): return a 3xx
43
+ # as-is instead of following it. HTTP::Tiny follows redirects by
44
+ # default; the station feature's hosts policy sets this so a Location
45
+ # off the allowlist can never pull an automatic credentialed follow-up.
46
+ my %new_args;
47
+ $new_args{max_redirect} = 0
48
+ if defined $fetchdef->{redirect} && !ref $fetchdef->{redirect}
49
+ && 'manual' eq $fetchdef->{redirect};
50
+
51
+ my $res = eval { HTTP::Tiny->new(%new_args)->request($method, $fullurl, $opts) };
43
52
  if (!$res) {
44
53
  my $e = defined $@ ? "$@" : 'request failed';
45
54
  $e =~ s/\s+\z//;
@@ -26,8 +26,20 @@ $REGISTRY{make_options} = sub {
26
26
  }
27
27
  }
28
28
 
29
+ # Feature INSTANCES supplied at construction (the station adopt path):
30
+ # consumed by the constructor's extend loop, so they are blessed class
31
+ # instances, not data. This is the perl form of the ts/rb targets'
32
+ # `extend => $ANY` optspec entry: this port deep-clones its options, and
33
+ # Voxgig::Struct::clone would flatten a blessed feature to a plain
34
+ # unblessed hash (and validate rejects the key outright), so the RAW
35
+ # list is captured here and re-attached after validation - the
36
+ # system.fetch idiom. Without this the seam is dead: the constructor
37
+ # reads options.extend, but clone/validate dropped the instances.
38
+ my $extend_raw = ProjectNameHelpers::gp($options, 'extend');
39
+
29
40
  my $opts = Voxgig::Struct::clone($options);
30
41
  $opts = {} unless Voxgig::Struct::ismap($opts);
42
+ delete $opts->{extend};
31
43
 
32
44
  # Feature add-order. options.feature may be given as an ordered ARRAY of
33
45
  # { name, active, ...opts } entries (the array position IS the order in
@@ -78,6 +90,12 @@ $REGISTRY{make_options} = sub {
78
90
  'system' => {},
79
91
  'test' => { 'active' => $JF, 'entity' => { '`$OPEN`' => $JT } },
80
92
  'clean' => { 'keys' => 'key,token,id' },
93
+ # Server-variable values for a templated base URL (OpenAPI server
94
+ # variables). The embedded config (configDefinition) carries the
95
+ # spec defaults; user values override them. This port does not yet
96
+ # substitute {name} placeholders into base - the entry keeps the
97
+ # config's server block valid under this optspec.
98
+ 'server' => { '`$CHILD`' => '' },
81
99
  };
82
100
 
83
101
  my $sys_fetch = ProjectNameHelpers::gpath($opts, 'system.fetch');
@@ -95,6 +113,11 @@ $REGISTRY{make_options} = sub {
95
113
  $opts->{system}{fetch} = $sys_fetch;
96
114
  }
97
115
 
116
+ # Re-attach the raw extend instances captured above.
117
+ if (Voxgig::Struct::islist($extend_raw)) {
118
+ $opts->{extend} = $extend_raw;
119
+ }
120
+
98
121
  my $clean_keys = ProjectNameHelpers::gpath($opts, 'clean.keys');
99
122
  $clean_keys = 'key,token,id' unless defined $clean_keys && !ref $clean_keys;
100
123
  my @parts;
@@ -114,6 +137,19 @@ $REGISTRY{make_options} = sub {
114
137
  @featureorder = (grep { 'test' eq $_ } @names)
115
138
  ? ('test', grep { 'test' ne $_ } @names)
116
139
  : @names;
140
+
141
+ # Station special case, mirroring test's: its transport wrap must
142
+ # sit immediately outside the base transport (inside retry/cache/
143
+ # netsim), so map-form activation hoists it to just after test -
144
+ # or first, when no test entry exists. Without this the sorted
145
+ # default would init station last and wrap OUTSIDE the recording
146
+ # features, turning its wire-truth events into fiction.
147
+ my ($si) = grep { 'station' eq $featureorder[$_] } 0 .. $#featureorder;
148
+ if (defined $si) {
149
+ splice @featureorder, $si, 1;
150
+ my ($ti) = grep { 'test' eq $featureorder[$_] } 0 .. $#featureorder;
151
+ splice @featureorder, (defined $ti ? $ti + 1 : 0), 0, 'station';
152
+ }
117
153
  }
118
154
 
119
155
  $opts->{__derived__} = {
@@ -154,6 +154,26 @@ class PipelineTest extends TestCase
154
154
  ]));
155
155
  }
156
156
 
157
+ // Station special case, mirroring test's: its transport wrap must sit
158
+ // immediately outside the base transport, so map-form activation hoists
159
+ // it to just after test - or first, when no test entry exists.
160
+ public function test_feature_order_map_hoists_station_after_test(): void
161
+ {
162
+ $this->assertSame('test,station,metrics', self::resolve_order([
163
+ 'metrics' => ['active' => true],
164
+ 'station' => ['active' => true],
165
+ 'test' => ['active' => true],
166
+ ]));
167
+ }
168
+
169
+ public function test_feature_order_map_hoists_station_first_without_test(): void
170
+ {
171
+ $this->assertSame('station,metrics', self::resolve_order([
172
+ 'metrics' => ['active' => true],
173
+ 'station' => ['active' => true],
174
+ ]));
175
+ }
176
+
157
177
  public function test_feature_order_no_test_deterministic(): void
158
178
  {
159
179
  $this->assertSame('cache,retry', self::resolve_order([
@@ -35,7 +35,8 @@ class ProjectNameFetcher
35
35
  // Prefer cURL when available — its header capture is reliable across
36
36
  // PHP versions, while file_get_contents + custom stream wrappers
37
37
  // don't propagate $http_response_header for user-defined wrappers
38
- // in PHP 8.3+.
38
+ // in PHP 8.3+. (cURL is configured with FOLLOWLOCATION off, so the
39
+ // manual-redirect annotation below holds there by construction.)
39
40
  if (function_exists('curl_init')) {
40
41
  return self::curlFetch($fullurl, $method_str, $body_str, $header_lines);
41
42
  }
@@ -47,6 +48,16 @@ class ProjectNameFetcher
47
48
  ],
48
49
  ];
49
50
 
51
+ // Honour a redirect annotation on the fetch definition (set by the
52
+ // station feature's middleware under a hosts policy): "manual"
53
+ // surfaces a 3xx as an ordinary response instead of auto-following
54
+ // it — the http stream wrapper otherwise replays the request,
55
+ // headers included, against whatever host Location names, carrying
56
+ // injected credentials to a host no policy approved.
57
+ if (($fetchdef['redirect'] ?? null) === 'manual') {
58
+ $opts['http']['follow_location'] = 0;
59
+ }
60
+
50
61
  if (is_string($body_str)) {
51
62
  $opts['http']['content'] = $body_str;
52
63
  }
@@ -37,6 +37,34 @@ class ProjectNameMakeOptions
37
37
  // Preserve system.fetch before merge/validate.
38
38
  $sys_fetch = \Voxgig\Struct\Struct::getpath($options, 'system.fetch');
39
39
 
40
+ // Feature INSTANCES supplied at construction (the station adopt path)
41
+ // are consumed by the constructor's feature-add loop straight from the
42
+ // RAW construction options - extend is consumed exactly once, at
43
+ // construction. Strip it here so it never enters the cloned option
44
+ // map: Struct::clone flattens arbitrary objects, and options_map()
45
+ // re-clones $this->options on every request.
46
+ if (isset($options['extend'])) {
47
+ unset($options['extend']);
48
+ }
49
+
50
+ // The station feature's binding handle and caller opts are live
51
+ // values (connect/adopt/st->options() plant them): hold them aside so
52
+ // the clone below cannot flatten them, and reattach after validate.
53
+ $station_handle = null;
54
+ $station_caller = null;
55
+ $station_has_caller = false;
56
+ if (is_array($options['feature']['station'] ?? null)) {
57
+ if (is_object($options['feature']['station']['station'] ?? null)) {
58
+ $station_handle = $options['feature']['station']['station'];
59
+ unset($options['feature']['station']['station']);
60
+ }
61
+ if (array_key_exists('calleropts', $options['feature']['station'])) {
62
+ $station_caller = $options['feature']['station']['calleropts'];
63
+ $station_has_caller = true;
64
+ unset($options['feature']['station']['calleropts']);
65
+ }
66
+ }
67
+
40
68
  $opts = \Voxgig\Struct\Struct::clone($options);
41
69
  $opts = self::to_array_deep($opts);
42
70
  if (!is_array($opts)) {
@@ -106,6 +134,24 @@ class ProjectNameMakeOptions
106
134
  $opts = [];
107
135
  }
108
136
 
137
+ // Reattach the station binding handle held aside above (the feature
138
+ // optspec is `$OPEN`, so the extra keys are legal; they were only
139
+ // kept out of the clone/validate round-trip).
140
+ if (null !== $station_handle || $station_has_caller) {
141
+ if (!is_array($opts['feature'] ?? null)) {
142
+ $opts['feature'] = [];
143
+ }
144
+ if (!is_array($opts['feature']['station'] ?? null)) {
145
+ $opts['feature']['station'] = [];
146
+ }
147
+ if (null !== $station_handle) {
148
+ $opts['feature']['station']['station'] = $station_handle;
149
+ }
150
+ if ($station_has_caller) {
151
+ $opts['feature']['station']['calleropts'] = $station_caller;
152
+ }
153
+ }
154
+
109
155
  // Resolve a templated base URL (e.g. https://{tenant_id}.hanko.io).
110
156
  // Every placeholder must resolve to a non-empty value: from
111
157
  // $options['server'] (user), else the config default. A placeholder
@@ -181,6 +227,18 @@ class ProjectNameMakeOptions
181
227
  } else {
182
228
  $featureorder = $names;
183
229
  }
230
+ // Station special case, mirroring test's: its transport wrap must
231
+ // sit immediately outside the base transport (inside retry/cache/
232
+ // netsim), so map-form activation hoists it to just after test -
233
+ // or first, when no test entry exists. Without this the sorted
234
+ // default would init station last and wrap OUTSIDE the recording
235
+ // features, turning its wire-truth events into fiction.
236
+ $si = array_search('station', $featureorder, true);
237
+ if (false !== $si) {
238
+ array_splice($featureorder, $si, 1);
239
+ $ti = array_search('test', $featureorder, true);
240
+ array_splice($featureorder, false === $ti ? 0 : $ti + 1, 0, ['station']);
241
+ }
184
242
  }
185
243
 
186
244
  $derived = ['clean' => $keyre === '' ? [] : ['keyre' => $keyre]];
@@ -35,8 +35,20 @@ def _default_http_fetch(fullurl, fetchdef):
35
35
  if not has_ua:
36
36
  req.add_header("User-Agent", _DEFAULT_USER_AGENT)
37
37
 
38
+ # Manual redirects: fetchdef["redirect"] == "manual" surfaces a 3xx as
39
+ # an ordinary response instead of auto-following it — urllib would
40
+ # otherwise replay the request, headers included, against whatever host
41
+ # Location names. Set by the station feature's transport middleware
42
+ # under an egress hosts policy (mirrors the ts fetch option).
43
+ opener = None
44
+ if fetchdef.get("redirect") == "manual":
45
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
46
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
47
+ return None
48
+ opener = urllib.request.build_opener(_NoRedirect)
49
+
38
50
  try:
39
- resp = urllib.request.urlopen(req)
51
+ resp = opener.open(req) if opener is not None else urllib.request.urlopen(req)
40
52
  body = resp.read().decode("utf-8")
41
53
  resp_headers = {}
42
54
  for k, v in resp.getheaders():
@@ -15,6 +15,15 @@ def make_options_util(ctx):
15
15
  for key, val in custom_utils.items():
16
16
  utility.custom[key] = val
17
17
 
18
+ # Feature INSTANCES supplied at construction (the station adopt path)
19
+ # are consumed by the constructor's feature-add loop straight from the
20
+ # RAW construction options - extend is consumed exactly once, at
21
+ # construction. They are stripped here so they never enter the cloned
22
+ # option map: vs.clone flattens arbitrary objects, and options_map()
23
+ # re-clones self.options on every request.
24
+ if isinstance(options, dict) and options.get("extend") is not None:
25
+ options = {k: v for k, v in options.items() if k != "extend"}
26
+
18
27
  opts = vs.clone(options)
19
28
  if not isinstance(opts, dict):
20
29
  opts = {}
@@ -74,6 +83,10 @@ def make_options_util(ctx):
74
83
  },
75
84
  },
76
85
  "utility": {},
86
+ # Extension feature instances (see above) - stripped before the
87
+ # clone, but the key stays legal so a passed-through map cannot
88
+ # fail validation.
89
+ "extend": "`$ANY`",
77
90
  "system": {},
78
91
  "test": {
79
92
  "active": False,
@@ -171,6 +184,16 @@ def make_options_util(ctx):
171
184
  featureorder = ["test"] + [n for n in names if n != "test"]
172
185
  else:
173
186
  featureorder = names
187
+ # Station special case, mirroring test's: its transport wrap must
188
+ # sit immediately outside the base transport (inside retry/cache/
189
+ # netsim), so map-form activation hoists it to just after test -
190
+ # or first, when no test entry exists. Without this the sorted
191
+ # default would init station last and wrap OUTSIDE the recording
192
+ # features, turning its wire-truth events into fiction.
193
+ if "station" in featureorder:
194
+ featureorder = [n for n in featureorder if n != "station"]
195
+ at = featureorder.index("test") + 1 if "test" in featureorder else 0
196
+ featureorder.insert(at, "station")
174
197
 
175
198
  derived = {"clean": {}}
176
199
  if keyre != "":
@@ -49,6 +49,12 @@ module ProjectNameUtilities
49
49
  "entity" => { "`$CHILD`" => { "`$OPEN`" => true, "active" => false, "alias" => {} } },
50
50
  "feature" => { "`$CHILD`" => { "`$OPEN`" => true, "active" => false } },
51
51
  "utility" => {},
52
+ # Feature INSTANCES supplied at construction (the station adopt
53
+ # path): consumed by the constructor's feature_add loop, so they are
54
+ # class instances, not data — `$ANY` accepts them verbatim. Without
55
+ # this entry the seam is dead: the constructor reads
56
+ # options["extend"], but validate rejected the key.
57
+ "extend" => "`$ANY`",
52
58
  "system" => {},
53
59
  "test" => { "active" => false, "entity" => { "`$OPEN`" => true } },
54
60
  "clean" => { "keys" => "key,token,id" },
@@ -122,6 +128,18 @@ module ProjectNameUtilities
122
128
  else
123
129
  featureorder = names
124
130
  end
131
+ # Station special case, mirroring test's: its transport wrap must
132
+ # sit immediately outside the base transport (inside retry/cache/
133
+ # netsim), so map-form activation hoists it to just after test -
134
+ # or first, when no test entry exists. Without this the sorted
135
+ # default would init station last and wrap OUTSIDE the recording
136
+ # features, turning its wire-truth events into fiction.
137
+ si = featureorder.index("station")
138
+ unless si.nil?
139
+ featureorder.delete_at(si)
140
+ ti = featureorder.index("test")
141
+ featureorder.insert(ti.nil? ? 0 : ti + 1, "station")
142
+ end
125
143
  end
126
144
 
127
145
  derived = { "clean" => keyre.empty? ? {} : { "keyre" => keyre } }
@@ -295,7 +295,7 @@ pub fn match_deep(check: &Value, base: &Value) -> Option<String> {
295
295
  // (.sdk/test/primary/<name>.aontu carries a PENDING header). Everything else
296
296
  // MUST contribute cases.
297
297
  pub const PENDING_SECTIONS: &[&str] = &[
298
- "fetcher", "makeFetchDef", "makePoint", "makeResult", "featureAdd",
298
+ "fetcher", "makeFetchDef", "makeResult", "featureAdd",
299
299
  "featureHook", "featureInit",
300
300
  ];
301
301
 
@@ -1826,6 +1826,33 @@ fn feature_proxy_inactive_does_not_wrap() {
1826
1826
  );
1827
1827
  }
1828
1828
 
1829
+ // --- station --------------------------------------------------------------------
1830
+
1831
+ // The station adapter (installed by @voxgig/sdkgen-station) with NO open
1832
+ // station must be an inert no-op that emits nothing and fails nothing
1833
+ // (station design 3.1). Constructed through the generated make_feature
1834
+ // factory - never a compile-time module reference - so this file stays
1835
+ // compilable both with and without the station feature installed: absent,
1836
+ // the factory answers with the inert BaseFeature and fh_present skips the
1837
+ // assertions. Bound behaviour (registration, injection, wire events) is
1838
+ // exercised by the station library's own suite and the consumer-side
1839
+ // station validation, not here.
1840
+ #[test]
1841
+ fn feature_station_inert_without_open_station() {
1842
+ if !fh_present(&["station"]) {
1843
+ return;
1844
+ }
1845
+ let f = RUSTCRATE::make_feature("station");
1846
+ assert_eq!(
1847
+ "station",
1848
+ f.borrow().name(),
1849
+ "generated factory has a station arm"
1850
+ );
1851
+ let h = fh_make(None, vec![(f, Value::Noval)]);
1852
+ let res = h.op(opspec("load"));
1853
+ assert!(res.ok, "expected inert station to pass the op through");
1854
+ }
1855
+
1829
1856
  // --- composition ----------------------------------------------------------------
1830
1857
 
1831
1858
  #[test]
@@ -19,6 +19,14 @@ fn default_http_fetch(fullurl: &str, fetchdef: &Value) -> Result<Value, ProjectN
19
19
  builder = builder.proxy(p);
20
20
  }
21
21
  }
22
+ // A `redirect: "manual"` annotation (set by the station feature when a
23
+ // hosts policy is active - the same in-band channel as `proxy` above)
24
+ // disables automatic redirect following, so a 3xx rides back as a normal
25
+ // response: a Location pointing off an egress allowlist must never pull
26
+ // an automatic credentialed follow-up request.
27
+ if get_str(fetchdef, "redirect").as_deref() == Some("manual") {
28
+ builder = builder.redirects(0);
29
+ }
22
30
  let agent = builder.build();
23
31
 
24
32
  let mut req = agent.request(&method, fullurl);