@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
@@ -4,7 +4,7 @@ import * as Path from 'node:path'
4
4
  import {
5
5
  cmp, each, camelify, names,
6
6
  File, Content, Folder, Fragment, Line, FeatureHook, Slot,
7
- opTypeName, entityClassName, entityCollection,
7
+ opTypeName, entityClassName, entityCollection, tsSafeTypeName,
8
8
  } from '@voxgig/sdkgen'
9
9
 
10
10
  import {
@@ -33,9 +33,14 @@ const Entity = cmp(function Entity(props: any) {
33
33
 
34
34
  names(entrep, entity.Name, 'EntityName')
35
35
 
36
+ // A TS/JS global (Record, Array, Promise, ...) would shadow itself for
37
+ // the rest of the file — see tsSafeTypeName. The runtime `this.Name`
38
+ // string stays entity.Name; only the TYPE reference is ever renamed.
39
+ const dataType = tsSafeTypeName(entity.Name)
40
+
36
41
  // Import exactly the typed models this entity references: its data type plus
37
42
  // one request type per ACTIVE op (matches what EntityTypes_ts.ts emits).
38
- const typeNames = [entity.Name]
43
+ const typeNames = [dataType]
39
44
  const opnamesAll = Object.keys(entity.op || {})
40
45
  ;['load', 'list', 'create', 'update', 'remove'].forEach((opname: string) => {
41
46
  if (opnamesAll.includes(opname)) {
@@ -69,6 +74,7 @@ const Entity = cmp(function Entity(props: any) {
69
74
  entityname: entity.name,
70
75
  SdkName: model.const.Name,
71
76
  EntityName: entity.Name,
77
+ EntityDataType: dataType,
72
78
 
73
79
  // Class token decoupled from the EntityName data-type token in
74
80
  // Entity.fragment.ts so the class can be renamed independently.
@@ -124,7 +124,9 @@ const Package = cmp(async function Package(props: any) {
124
124
  }
125
125
 
126
126
  File({ name: 'package.json' }, () => {
127
- Content(JSON.stringify(pkg, null, 2))
127
+ // Trailing newline: POSIX wants one, and without it every diff of a
128
+ // regenerated package.json reports "".
129
+ Content(JSON.stringify(pkg, null, 2) + '\n')
128
130
  })
129
131
  })
130
132
 
@@ -33,6 +33,24 @@ const ReadmeIntro = cmp(function ReadmeIntro(props: any) {
33
33
  const opList = (opNames.length ? opNames : ['list', 'load'])
34
34
  .map((o) => '`' + o + '`').join(', ')
35
35
 
36
+ // Sibling surfaces, MODEL-DRIVEN. This line used to hardcode "the CLI, and
37
+ // MCP server" — those are the `go-cli` and `go-mcp` targets, and this project
38
+ // has neither. So both per-language READMEs advertised surfaces nothing here
39
+ // generates, while the top-level Surfaces table (which does gate on active
40
+ // targets) listed only ts/ and go/. Name what is actually active, and drop
41
+ // the sentence entirely when this is the only surface, so it cannot outrun
42
+ // the model again.
43
+ const targets = getModelPath(model, `main.${KIT}.target`) || {}
44
+ const siblings = Object.entries(targets)
45
+ .filter(([name, t]: any) => name !== target.name && false !== t.active)
46
+ .map(([name]: any) => name)
47
+ .sort()
48
+
49
+ const siblingNote = 0 === siblings.length ? '' : `
50
+ > Also generated from this model: ${siblings.map((s: string) => '`' + s + '`').join(', ')} — see
51
+ > the [top-level README](../README.md).
52
+ `
53
+
36
54
  Content(`# ${model.Name} ${target.title} SDK
37
55
 
38
56
  ${tagline}
@@ -43,10 +61,7 @@ The API is exposed as capitalised, semantic **Entities** — e.g.
43
61
  \`client.${eName}()\` — each with a small set of operations (${opList})
44
62
  instead of raw URL paths and query parameters. This keeps the surface
45
63
  predictable and low-friction for both humans and AI agents.
46
-
47
- > Other languages, the CLI, and MCP server live alongside this one — see
48
- > the [top-level README](../README.md).
49
-
64
+ ${siblingNote}
50
65
  `)
51
66
  })
52
67
 
@@ -34,6 +34,13 @@ class Config {
34
34
  return fi
35
35
  }
36
36
 
37
+ // False for a feature added at runtime via options.extend (station's
38
+ // adopt path) - the constructor uses this to skip makeFeature for names
39
+ // no generated class backs.
40
+ hasFeature(this: any, fn: string) {
41
+ return null != FEATURE_CLASS[fn]
42
+ }
43
+
37
44
  }
38
45
 
39
46
 
@@ -17,9 +17,17 @@ class Config {
17
17
  return fi
18
18
  }
19
19
 
20
+ // False for a feature added at runtime via options.extend (station's
21
+ // adopt path) - the constructor uses this to skip makeFeature for names
22
+ // no generated class backs.
23
+ hasFeature(this: any, fn: string) {
24
+ return null != FEATURE_CLASS[fn]
25
+ }
26
+
20
27
 
21
28
  main = {
22
29
  name: '$$const.Name$$',
30
+ // #MainMeta
23
31
  }
24
32
 
25
33
 
@@ -18,7 +18,7 @@ import type {
18
18
 
19
19
 
20
20
  // TODO: needs Entity superclass
21
- class EntyClass extends ProjectNameEntityBase<EntityName> {
21
+ class EntyClass extends ProjectNameEntityBase<EntityDataType> {
22
22
 
23
23
  constructor(client: ProjectNameSDK, entopts: any) {
24
24
  super(client, entopts)
@@ -52,18 +52,26 @@ class ProjectNameSDK {
52
52
  // the `test` feature installs the base mock transport and the transport
53
53
  // features (retry/cache/netsim/proxy/ratelimit) wrap whatever is current,
54
54
  // so `test` must be added before them to sit at the base of the chain.
55
+ const extend = this._options.extend || []
56
+
55
57
  const featureorder = getpath(this._options, '__derived__.featureorder') || []
56
58
  for (const fname of featureorder) {
57
59
  const fopts = this._options.feature[fname] || {}
58
60
  if (fopts.active) {
61
+ // An active name with no generated class is legal when an
62
+ // extend-supplied instance carries that name (station's adopt
63
+ // path): the instance is added below, positioned by its own
64
+ // __after__ entry, so skip it here rather than fail construction.
65
+ if (!this._rootctx.config.hasFeature(fname) &&
66
+ extend.some((f: any) => fname === f.name)) {
67
+ continue
68
+ }
59
69
  featureAdd(this._rootctx, this._rootctx.config.makeFeature(fname))
60
70
  }
61
71
  }
62
72
 
63
- if (null != this._options.extend) {
64
- for (let f of this._options.extend) {
65
- featureAdd(this._rootctx, f)
66
- }
73
+ for (let f of extend) {
74
+ featureAdd(this._rootctx, f)
67
75
  }
68
76
 
69
77
  for (let f of this._features) {
@@ -7,7 +7,12 @@ CFLAGS ?= -std=c11 -Wall -Wextra -Wno-unused-parameter -D_GNU_SOURCE -O0 -g
7
7
  INC := -I core -I utility/struct
8
8
  LDLIBS := -lm
9
9
 
10
- LIB_SRCS := $(wildcard core/*.c utility/*.c utility/struct/*.c feature/*.c entity/*.c)
10
+ # feature/*/*.c: a feature may carry a vendored library payload in its own
11
+ # subdirectory (the station feature vendors voxgig_station there - C has no
12
+ # package registry, so tier-C libraries ride the feature source; see
13
+ # utility/struct/ for the same precedent at the utility level). Empty when
14
+ # no feature ships one.
15
+ LIB_SRCS := $(wildcard core/*.c utility/*.c utility/struct/*.c feature/*.c feature/*/*.c entity/*.c)
11
16
  LIB_OBJS := $(LIB_SRCS:.c=.o)
12
17
 
13
18
  TEST_SRCS := $(wildcard tests/*.c)
@@ -166,8 +166,19 @@ voxgig_value* make_options_util(Context* ctx) {
166
166
  if (has_test) {
167
167
  voxgig_list_push(voxgig_as_list(feature_order), v_str("test"));
168
168
  }
169
+ // Station special case, mirroring test's: its transport wrap must
170
+ // sit immediately outside the base transport (inside retry/cache/
171
+ // netsim), so map-form activation hoists it to just after test -
172
+ // or first, when no test entry exists. Without this the sorted
173
+ // default would init station last and wrap OUTSIDE the recording
174
+ // features, turning its wire-truth events into fiction.
169
175
  for (size_t i = 0; i < fn; i++) {
170
- if (strcmp(names[i], "test") != 0) {
176
+ if (strcmp(names[i], "station") == 0) {
177
+ voxgig_list_push(voxgig_as_list(feature_order), v_str("station"));
178
+ }
179
+ }
180
+ for (size_t i = 0; i < fn; i++) {
181
+ if (strcmp(names[i], "test") != 0 && strcmp(names[i], "station") != 0) {
171
182
  voxgig_list_push(voxgig_as_list(feature_order), v_str(names[i]));
172
183
  }
173
184
  }
@@ -1211,8 +1211,16 @@ inline Value makeOptions(CtxPtr ctx) {
1211
1211
  std::sort(names.begin(), names.end());
1212
1212
  bool hasTest = std::find(names.begin(), names.end(), "test") != names.end();
1213
1213
  if (hasTest) featureOrder.push_back("test");
1214
+ // Station special case, mirroring test's: its transport wrap must
1215
+ // sit immediately outside the base transport (inside retry/cache/
1216
+ // netsim), so map-form activation hoists it to just after test -
1217
+ // or first, when no test entry exists. Without this the sorted
1218
+ // default would init station last and wrap OUTSIDE the recording
1219
+ // features, turning its wire-truth events into fiction.
1220
+ bool hasStation = std::find(names.begin(), names.end(), "station") != names.end();
1221
+ if (hasStation) featureOrder.push_back("station");
1214
1222
  for (const auto& n : names) {
1215
- if (n != "test") featureOrder.push_back(n);
1223
+ if (n != "test" && n != "station") featureOrder.push_back(n);
1216
1224
  }
1217
1225
  }
1218
1226
  }
@@ -13,30 +13,48 @@ public static partial class SdkUtility
13
13
  {
14
14
  private static readonly HttpClient DefaultHttpClient = new();
15
15
 
16
- // Proxy-routed clients, cached per proxy URL (see the proxy feature's
17
- // fetchdef annotation).
16
+ // Non-following twin of DefaultHttpClient. The station feature's
17
+ // middleware sets `redirect: manual` on the fetch definition under a
18
+ // hosts egress policy: a 3xx must come back as a response like any
19
+ // other, because an automatic follow would carry injected credentials
20
+ // to a Location no policy approved (the ts donor's fetch honours the
21
+ // same key natively; redirect policy is per-handler in .NET, hence a
22
+ // second client).
23
+ private static readonly HttpClient ManualRedirectHttpClient = new(
24
+ new HttpClientHandler
25
+ {
26
+ AllowAutoRedirect = false,
27
+ });
28
+
29
+ // Proxy-routed clients, cached per proxy URL and redirect policy (see
30
+ // the proxy feature's fetchdef annotation).
18
31
  private static readonly Dictionary<string, HttpClient> ProxyClients = new();
19
32
  private static readonly object ProxyClientsLock = new();
20
33
 
21
34
  private static HttpClient ClientFor(Dictionary<string, object?> fetchdef)
22
35
  {
36
+ var manual = fetchdef.TryGetValue("redirect", out var rraw) &&
37
+ rraw is string redirect && redirect == "manual";
38
+
23
39
  if (fetchdef.TryGetValue("proxy", out var raw) && raw is string proxy && proxy != "")
24
40
  {
25
41
  lock (ProxyClientsLock)
26
42
  {
27
- if (!ProxyClients.TryGetValue(proxy, out var client))
43
+ var key = (manual ? "manual|" : "auto|") + proxy;
44
+ if (!ProxyClients.TryGetValue(key, out var client))
28
45
  {
29
46
  client = new HttpClient(new HttpClientHandler
30
47
  {
31
48
  Proxy = new System.Net.WebProxy(proxy),
32
49
  UseProxy = true,
50
+ AllowAutoRedirect = !manual,
33
51
  });
34
- ProxyClients[proxy] = client;
52
+ ProxyClients[key] = client;
35
53
  }
36
54
  return client;
37
55
  }
38
56
  }
39
- return DefaultHttpClient;
57
+ return manual ? ManualRedirectHttpClient : DefaultHttpClient;
40
58
  }
41
59
 
42
60
  internal static Dictionary<string, object?> DefaultHttpFetch(
@@ -248,6 +248,19 @@ public static partial class SdkUtility
248
248
  featureorder.Add(n);
249
249
  }
250
250
  }
251
+
252
+ // Station special case, mirroring test's: its transport wrap must
253
+ // sit immediately outside the base transport (inside retry/cache/
254
+ // netsim), so map-form activation hoists it to just after test -
255
+ // or first, when no test entry exists. Without this the sorted
256
+ // default would init station last and wrap OUTSIDE the recording
257
+ // features, turning its wire-truth events into fiction.
258
+ var si = featureorder.IndexOf("station");
259
+ if (si >= 0)
260
+ {
261
+ featureorder.RemoveAt(si);
262
+ featureorder.Insert(featureorder.IndexOf("test") + 1, "station");
263
+ }
251
264
  }
252
265
 
253
266
  var derived = new Dictionary<string, object?>
@@ -46,6 +46,15 @@ Future<dynamic> httpFetch(dynamic fullurl, dynamic fetchdef) async {
46
46
 
47
47
  final req = await client.openUrl(method, uri);
48
48
 
49
+ // Manual redirects: fetchdef['redirect'] == 'manual' surfaces a 3xx as
50
+ // an ordinary response instead of auto-following it — HttpClient would
51
+ // otherwise replay the request, headers included, against whatever host
52
+ // Location names. Set by the station feature's transport middleware
53
+ // under an egress hosts policy (mirrors the ts fetch option).
54
+ if ('manual' == vs.getprop(fetchdef, 'redirect')) {
55
+ req.followRedirects = false;
56
+ }
57
+
49
58
  final headers = vs.getprop(fetchdef, 'headers');
50
59
  if (headers is Map) {
51
60
  headers.forEach((k, v) {
@@ -47,6 +47,12 @@ dynamic makeOptions(dynamic ctx) {
47
47
  },
48
48
  },
49
49
  'utility': {},
50
+ // Feature INSTANCES supplied at construction (the station adopt
51
+ // path): consumed by the constructor's featureAdd loop, so they are
52
+ // class instances, not data - `$ANY` accepts them verbatim. Without
53
+ // this entry the seam is dead: the constructor reads
54
+ // options.extend, but validate rejected the key.
55
+ 'extend': '`\$ANY`',
50
56
  'system': {},
51
57
  'test': {
52
58
  'active': false,
@@ -177,6 +183,17 @@ dynamic makeOptions(dynamic ctx) {
177
183
  } else {
178
184
  featureorder.addAll(names);
179
185
  }
186
+ // Station special case, mirroring test's: its transport wrap must
187
+ // sit immediately outside the base transport (inside retry/cache/
188
+ // netsim), so map-form activation hoists it to just after test -
189
+ // or first, when no test entry exists. Without this the sorted
190
+ // default would init station last and wrap OUTSIDE the recording
191
+ // features, turning its wire-truth events into fiction.
192
+ if (featureorder.contains('station')) {
193
+ featureorder.remove('station');
194
+ final ti = featureorder.indexOf('test');
195
+ featureorder.insert(-1 == ti ? 0 : ti + 1, 'station');
196
+ }
180
197
  }
181
198
 
182
199
  opts['__derived__'] = {
@@ -261,6 +261,12 @@ defmodule ProjectName.Utility do
261
261
  "entity" => %{"`$CHILD`" => %{"`$OPEN`" => true, "active" => false, "alias" => %{}}},
262
262
  "feature" => %{"`$CHILD`" => %{"`$OPEN`" => true, "active" => false}},
263
263
  "utility" => %{},
264
+ # Feature INSTANCES supplied at construction (the station adopt
265
+ # path): consumed by the constructor's extend loop, so they are
266
+ # struct feature nodes, not data - `$ANY` accepts them verbatim.
267
+ # Without this entry the seam is dead: the constructor reads
268
+ # options.extend, but validate rejected the key.
269
+ "extend" => "`$ANY`",
264
270
  "system" => %{},
265
271
  "test" => %{"active" => false, "entity" => %{"`$OPEN`" => true}},
266
272
  "clean" => %{"keys" => "key,token,id"},
@@ -359,8 +365,29 @@ defmodule ProjectName.Utility do
359
365
  fmap = if S.ismap(fmap), do: fmap, else: S.jm([])
360
366
  names = S.keysof(fmap)
361
367
 
362
- if Enum.member?(names, "test") do
363
- ["test" | Enum.reject(names, &(&1 == "test"))]
368
+ names =
369
+ if Enum.member?(names, "test") do
370
+ ["test" | Enum.reject(names, &(&1 == "test"))]
371
+ else
372
+ names
373
+ end
374
+
375
+ # Station special case, mirroring test's: its transport wrap must
376
+ # sit immediately outside the base transport (inside retry/cache/
377
+ # netsim), so map-form activation hoists it to just after test -
378
+ # or first, when no test entry exists. Without this the sorted
379
+ # default would init station last and wrap OUTSIDE the recording
380
+ # features, turning its wire-truth events into fiction.
381
+ if Enum.member?(names, "station") do
382
+ rest = Enum.reject(names, &(&1 == "station"))
383
+
384
+ at =
385
+ case Enum.find_index(rest, &(&1 == "test")) do
386
+ nil -> 0
387
+ ti -> ti + 1
388
+ end
389
+
390
+ List.insert_at(rest, at, "station")
364
391
  else
365
392
  names
366
393
  end
@@ -1518,7 +1545,16 @@ defmodule ProjectName.Utility do
1518
1545
  {url, hlist}
1519
1546
  end
1520
1547
 
1521
- case :httpc.request(method, request, [], body_format: :binary) do
1548
+ # A `redirect: "manual"` annotation (set by the station feature when a
1549
+ # hosts egress policy is active - the same in-band channel go and rust
1550
+ # use) disables :httpc's automatic redirect following, so a 3xx rides
1551
+ # back as a normal response: a Location pointing off an egress
1552
+ # allowlist must never pull an automatic credentialed follow-up
1553
+ # request.
1554
+ http_opts =
1555
+ if S.getprop(fetchdef, "redirect") == "manual", do: [autoredirect: false], else: []
1556
+
1557
+ case :httpc.request(method, request, http_opts, body_format: :binary) do
1522
1558
  {:ok, {{_v, status, _reason}, resp_headers, resp_body}} ->
1523
1559
  rh =
1524
1560
  Enum.reduce(resp_headers, S.jm([]), fn {k, v}, acc ->
@@ -50,6 +50,25 @@ defmodule ProjectName.FeatureTest do
50
50
  assert has?("test")
51
51
  end
52
52
 
53
+ # === station ===
54
+
55
+ # The station adapter (installed by @voxgig/sdkgen-station) with NO open
56
+ # station must be an inert no-op that emits nothing and fails nothing
57
+ # (station design 3.1). Driven through the generated factory - never a
58
+ # compile-time module reference - so this file stays green both with and
59
+ # without the feature installed (absent, the harness simply adds no
60
+ # feature). Bound behaviour (registration, injection, wire events) is
61
+ # exercised by the station library's own suite and the consumer-side
62
+ # station validation, not here.
63
+ test "station adapter with no open station is an inert no-op" do
64
+ h = harness([fspec("station")])
65
+ res = FH.op(h, %{})
66
+ assert res.ok == true
67
+
68
+ # Inert: the transport is neither wrapped nor marked.
69
+ assert S.getprop(h.utility, "__station_wrap__") == nil
70
+ end
71
+
53
72
  # === netsim ===
54
73
 
55
74
  test "netsim fixed latency then delegate" do
@@ -112,6 +112,78 @@ defmodule ProjectName.PrimaryUtilityTest do
112
112
  assert S.getpath(opts, "__derived__.clean.keyre") == "key|token|id"
113
113
  end
114
114
 
115
+ # --- feature add-order ----------------------------------------------------
116
+ # options.feature accepts an ordered LIST (developer add-order) or a map
117
+ # (defaults test-first); make_options records the resolved order in
118
+ # __derived__.featureorder. Driven with a synthetic ctx (empty config
119
+ # options) so the assertions do not depend on this SDK's own features.
120
+
121
+ defp resolve_order(feature) do
122
+ ctx =
123
+ S.jm([
124
+ "utility", Utility.new(),
125
+ "options", H.deep(%{"feature" => feature}),
126
+ "config", H.deep(%{"options" => %{}})
127
+ ])
128
+
129
+ fo = S.getpath(Utility.make_options(ctx), "__derived__.featureorder")
130
+ n = S.size(fo)
131
+ order = if n == 0, do: [], else: Enum.map(0..(n - 1), fn i -> S.getelem(fo, i) end)
132
+ Enum.join(order, ",")
133
+ end
134
+
135
+ test "feature order: map form is test-first" do
136
+ assert "test,metrics" ==
137
+ resolve_order(%{"metrics" => %{"active" => true}, "test" => %{"active" => true}})
138
+ end
139
+
140
+ test "feature order: an explicit list preserves its order" do
141
+ assert "metrics,test" ==
142
+ resolve_order([
143
+ %{"name" => "metrics", "active" => true},
144
+ %{"name" => "test", "active" => true}
145
+ ])
146
+ end
147
+
148
+ # Station special case, mirroring test's: its transport wrap must sit
149
+ # immediately outside the base transport, so map-form activation hoists
150
+ # it to just after test - or first, when no test entry exists.
151
+ test "feature order: map form hoists station after test" do
152
+ assert "test,station,metrics" ==
153
+ resolve_order(%{
154
+ "metrics" => %{"active" => true},
155
+ "station" => %{"active" => true},
156
+ "test" => %{"active" => true}
157
+ })
158
+ end
159
+
160
+ test "feature order: station first when no test entry exists" do
161
+ assert "station,metrics" ==
162
+ resolve_order(%{
163
+ "metrics" => %{"active" => true},
164
+ "station" => %{"active" => true}
165
+ })
166
+ end
167
+
168
+ # The extend seam (feature INSTANCES supplied at construction - the
169
+ # station adopt path): validate must accept the key verbatim, or the
170
+ # constructor's extend loop reads a stripped option and the seam is dead.
171
+ test "make_options preserves options.extend through validation" do
172
+ marker = S.jm(["name", "station"])
173
+
174
+ ctx =
175
+ S.jm([
176
+ "utility", Utility.new(),
177
+ "options", S.jm(["extend", S.jt([marker])]),
178
+ "config", H.deep(%{"options" => %{}})
179
+ ])
180
+
181
+ extend = S.getprop(Utility.make_options(ctx), "extend")
182
+ assert S.islist(extend)
183
+ assert S.size(extend) == 1
184
+ assert S.getprop(S.getelem(extend, 0), "name") == "station"
185
+ end
186
+
115
187
  test "make_error formats a namespaced message and raises by default" do
116
188
  c = client()
117
189
  ctx = ctx(c, "load")
@@ -1,6 +1,7 @@
1
1
  package sdktest
2
2
 
3
3
  import (
4
+ "errors"
4
5
  "fmt"
5
6
  "strings"
6
7
  "testing"
@@ -556,27 +557,48 @@ func TestPrimaryUtility(t *testing.T) {
556
557
  })
557
558
  })
558
559
 
560
+ // Was one hand-written case (the single-point path) covering one of this
561
+ // utility's seven branches, which is how the corpus fixture came to be
562
+ // marked deferred as "needs a real client". It does not: NewContext
563
+ // rebuilds Op from opname + entity + config, and Options can be supplied
564
+ // literally. Driven from the corpus now, so TS and Go assert the same
565
+ // branches.
566
+ //
567
+ // TS returns the error AS the value; Go returns it as a second result. The
568
+ // corpus says `match: out: code` for both, so the error is normalised to a
569
+ // map carrying its code here rather than forking the fixture per language.
559
570
  t.Run("makePoint-basic", func(t *testing.T) {
560
- ctx := makeTestCtx(client, utility, nil)
561
- point := map[string]any{
562
- "parts": []any{"items", "{id}"},
563
- "args": map[string]any{"params": []any{}},
564
- "params": []any{},
565
- "alias": map[string]any{},
566
- "select": map[string]any{},
567
- "active": true,
568
- "transform": map[string]any{},
569
- }
570
- ctx.Op.Points = []map[string]any{point}
571
-
572
- _, err := utility.MakePoint(ctx)
573
- if err != nil {
574
- t.Errorf("expected no error, got: %v", err)
575
- return
576
- }
577
- if ctx.Point == nil {
578
- t.Error("expected point to be set")
579
- }
571
+ runsetNamed(t, "makePoint", getSpec(primary, "makePoint", "basic"), func(entry map[string]any) (any, error) {
572
+ ctxmap, _ := entry["ctx"].(map[string]any)
573
+
574
+ // NewContext resolves Op from the ENTITY NAME, and reaches it
575
+ // through the Entity interface — a literal {name:...} map from the
576
+ // fixture is not one, so entname would be "" and every lookup would
577
+ // miss, reporting point_no_points for all seven cases. TS reads the
578
+ // same field with getprop and accepts the plain map. Swap in the
579
+ // package's minimal Entity so both ports resolve the same op.
580
+ if em, ok := ctxmap["entity"].(map[string]any); ok {
581
+ name, _ := em["name"].(string)
582
+ made := []any{}
583
+ swapped := map[string]any{}
584
+ for k, v := range ctxmap {
585
+ swapped[k] = v
586
+ }
587
+ swapped["entity"] = &plEntity{name: name, made: &made}
588
+ ctxmap = swapped
589
+ }
590
+
591
+ ctx := makeCtxFromMap(ctxmap, client, utility)
592
+ point, err := utility.MakePoint(ctx)
593
+ if err != nil {
594
+ var sdkErr *sdk.ProjectNameError
595
+ if errors.As(err, &sdkErr) {
596
+ return map[string]any{"code": sdkErr.Code}, nil
597
+ }
598
+ return nil, err
599
+ }
600
+ return point, nil
601
+ })
580
602
  })
581
603
 
582
604
  t.Run("makeUrl-basic", func(t *testing.T) {
@@ -218,7 +218,7 @@ type RunSubject func(entry map[string]any) (any, error)
218
218
  // PENDING sections are the ones deliberately left empty in the shared corpus
219
219
  // (.sdk/test/primary/<name>.aontu). Everything else MUST contribute cases.
220
220
  var pendingSections = map[string]bool{
221
- "fetcher": true, "makeFetchDef": true, "makePoint": true, "makeResult": true,
221
+ "fetcher": true, "makeFetchDef": true, "makeResult": true,
222
222
  "featureAdd": true, "featureHook": true, "featureInit": true,
223
223
  }
224
224
 
@@ -57,6 +57,20 @@ func defaultHTTPFetch(fullurl string, fetchdef map[string]any) (map[string]any,
57
57
  }
58
58
  }
59
59
 
60
+ // Honour a redirect annotation: "manual" surfaces a 3xx as an ordinary
61
+ // response instead of auto-following it. In the ts/js targets fetchdef
62
+ // rides straight into fetch(), where `redirect` is a native option -
63
+ // this is the same seam for Go's auto-following http.Client. The
64
+ // station feature sets it under a hosts egress policy, so a Location
65
+ // off the allowlist cannot pull an automatic credentialed follow-up.
66
+ if redirect, ok := fetchdef["redirect"].(string); ok && redirect == "manual" {
67
+ manual := *client
68
+ manual.CheckRedirect = func(req *http.Request, via []*http.Request) error {
69
+ return http.ErrUseLastResponse
70
+ }
71
+ client = &manual
72
+ }
73
+
60
74
  resp, err := client.Do(req)
61
75
  if err != nil {
62
76
  return nil, err