@voxgig/sdkgen 4.17.5 → 4.18.0

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 (61) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/dist/cmp/AgentGuideContent.js +30 -0
  3. package/dist/cmp/AgentGuideContent.js.map +1 -1
  4. package/dist/cmp/FeatureDocs.d.ts +6 -1
  5. package/dist/cmp/FeatureDocs.js +27 -0
  6. package/dist/cmp/FeatureDocs.js.map +1 -1
  7. package/dist/cmp/ReadmeRefFeatures.js +23 -13
  8. package/dist/cmp/ReadmeRefFeatures.js.map +1 -1
  9. package/dist/helpers/applicability.js +7 -0
  10. package/dist/helpers/applicability.js.map +1 -1
  11. package/dist/helpers/canonSpec.d.ts +12 -0
  12. package/dist/helpers/canonSpec.js +259 -0
  13. package/dist/helpers/canonSpec.js.map +1 -0
  14. package/dist/helpers/optspec.d.ts +4 -0
  15. package/dist/helpers/optspec.js +152 -0
  16. package/dist/helpers/optspec.js.map +1 -0
  17. package/dist/sdkgen.d.ts +3 -1
  18. package/dist/sdkgen.js +12 -3
  19. package/dist/sdkgen.js.map +1 -1
  20. package/dist/tsconfig.tsbuildinfo +1 -1
  21. package/model/sdkgen.aon +125 -0
  22. package/package.json +1 -1
  23. package/project/.sdk/model/feature/audit.aon +15 -0
  24. package/project/.sdk/model/feature/cache.aon +13 -0
  25. package/project/.sdk/model/feature/clienttrack.aon +21 -0
  26. package/project/.sdk/model/feature/cost.aon +15 -0
  27. package/project/.sdk/model/feature/debug.aon +15 -0
  28. package/project/.sdk/model/feature/feature-index.aon +1 -0
  29. package/project/.sdk/model/feature/idempotency.aon +13 -0
  30. package/project/.sdk/model/feature/log.aon +13 -0
  31. package/project/.sdk/model/feature/metrics.aon +13 -0
  32. package/project/.sdk/model/feature/netsim.aon +14 -0
  33. package/project/.sdk/model/feature/paging.aon +15 -0
  34. package/project/.sdk/model/feature/proxy.aon +13 -0
  35. package/project/.sdk/model/feature/ratelimit.aon +15 -0
  36. package/project/.sdk/model/feature/retry.aon +15 -0
  37. package/project/.sdk/model/feature/streaming.aon +15 -0
  38. package/project/.sdk/model/feature/telemetry.aon +19 -0
  39. package/project/.sdk/model/feature/test.aon +13 -0
  40. package/project/.sdk/model/feature/timeout.aon +15 -0
  41. package/project/.sdk/model/feature/validate.aon +73 -0
  42. package/project/.sdk/model/target/js.aon +1 -1
  43. package/project/.sdk/model/target/ts.aon +1 -1
  44. package/project/.sdk/src/cmp/js/Main_js.ts +2 -0
  45. package/project/.sdk/src/cmp/js/Schema_js.ts +66 -0
  46. package/project/.sdk/src/cmp/scala/Main_scala.ts +30 -0
  47. package/project/.sdk/src/cmp/ts/Main_ts.ts +2 -0
  48. package/project/.sdk/src/cmp/ts/Schema_ts.ts +66 -0
  49. package/project/.sdk/tm/js/src/feature/validate/ValidateFeature.js +296 -0
  50. package/project/.sdk/tm/js/src/utility/MakeOptionsUtility.js +26 -61
  51. package/project/.sdk/tm/ts/src/feature/validate/ValidateFeature.ts +298 -0
  52. package/project/.sdk/tm/ts/src/utility/MakeOptionsUtility.ts +24 -61
  53. package/project/.sdk/tm/ts/test/feature/secrets/Secrets.test.ts +37 -8
  54. package/project/sdkgen-package.json +3 -2
  55. package/src/cmp/AgentGuideContent.ts +30 -0
  56. package/src/cmp/FeatureDocs.ts +39 -0
  57. package/src/cmp/ReadmeRefFeatures.ts +24 -13
  58. package/src/helpers/applicability.ts +8 -0
  59. package/src/helpers/canonSpec.ts +301 -0
  60. package/src/helpers/optspec.ts +180 -0
  61. package/src/sdkgen.ts +9 -0
@@ -0,0 +1,298 @@
1
+ import type { Context, FeatureOptions } from '../../types'
2
+ import type { ProjectNameSDK } from '../../ProjectNameSDK'
3
+
4
+ import { ENTITYSPEC } from '../../Schema'
5
+
6
+ import { BaseFeature } from '../base/BaseFeature'
7
+
8
+
9
+ // Payload validation against the model's own field types.
10
+ //
11
+ // The specs are NOT written here and not written in the model either: every
12
+ // entity field already carries a canonical type sentinel (`$STRING`,
13
+ // `$INTEGER`, the `$ONE` union for an OpenAPI multi-type), which is the same
14
+ // vocabulary struct.validate speaks. The generator maps them once
15
+ // (helpers/canonSpec) and emits `ENTITYSPEC` beside this file, so a field
16
+ // whose type changes in the API spec changes what this feature enforces with
17
+ // no edit anywhere.
18
+ //
19
+ // WHAT IS CHECKED
20
+ // outbound (PreSpec) the payload the caller asked to send, against
21
+ // `spec.op[<opname>]` — the operation's request shape,
22
+ // which is the SAME partiality policy that decides what
23
+ // the generated `<Name>CreateData` type requires.
24
+ // inbound (PreDone) each record the operation returned, against
25
+ // `spec.data` — the entity's own field types.
26
+ //
27
+ // WHAT IS NOT. The model carries no array element types, no nested object
28
+ // schemas, no enums, formats or bounds (see canonSpec's note), so this checks
29
+ // the shape the model knows and nothing more. It is a guard against the
30
+ // mistakes the model CAN see — a number where a string belongs, a required
31
+ // field left out, a misspelled key under `strict` — not a substitute for the
32
+ // server's own validation.
33
+ class ValidateFeature extends BaseFeature {
34
+ version = '0.0.1'
35
+ name = 'validate'
36
+ active = true
37
+
38
+ _client?: ProjectNameSDK
39
+ _options: any = {}
40
+ _spec: Record<string, any> = {}
41
+
42
+ _request = true
43
+ _response = false
44
+ _mode = 'throw'
45
+
46
+
47
+ init(ctx: Context, options: FeatureOptions): void | Promise<any> {
48
+ this._client = ctx.client
49
+ this._options = options || {}
50
+ this.active = (options as any).active
51
+
52
+ // DEFAULTS ARE APPLIED HERE, not by the option spec. The model's
53
+ // `config.options` documents them and types them; it does not inject
54
+ // them, because each feature entry in the spec is optional and struct
55
+ // fills in nothing through an optional union. So every feature resolves
56
+ // its own — and a `mode` left undefined here once meant `'throw' !==
57
+ // undefined`, which silently turned every rejection into a no-op.
58
+ this._request = false !== this._options.request
59
+ this._response = true === this._options.response
60
+
61
+ // FAIL CLOSED. Only the exact string 'report' selects report mode, so a
62
+ // typo (`mode: 'thow'`) still rejects rather than silently turning
63
+ // enforcement off — the failure nobody would notice. The option spec
64
+ // rejects the typo outright; this is what happens if it ever does not.
65
+ this._mode = 'report' === this._options.mode ? 'report' : 'throw'
66
+
67
+ // `strict` is applied ONCE, here, by rebuilding the spec tree without the
68
+ // `$OPEN` markers — rather than per call, which would clone a spec for
69
+ // every request an SDK ever makes.
70
+ this._spec = true === this._options.strict ? close(ENTITYSPEC) : ENTITYSPEC
71
+ }
72
+
73
+
74
+ // Outbound. `makeSpec` short-circuits on an `ctx.out.spec` that is already
75
+ // set, so assigning the error here rejects the operation before the request
76
+ // is built — the same seam rbac uses one stage earlier.
77
+ PreSpec(this: any, ctx: any) {
78
+ if (!this.active || !this._request) {
79
+ return
80
+ }
81
+
82
+ const opname = (ctx.op && ctx.op.name) || ''
83
+ const spec = this._entitySpec(ctx)
84
+ const opspec = spec && spec.op ? spec.op[opname] : null
85
+
86
+ if (null == opspec) {
87
+ return
88
+ }
89
+
90
+ const errs = this._check(ctx, this._payload(ctx, opname), opspec, 'request')
91
+ if (0 === errs.length || 'report' === this._mode) {
92
+ return
93
+ }
94
+
95
+ const err = ctx.error('validate_failed',
96
+ 'Invalid ' + opname + ' request for entity "' + entname(ctx) + '": ' +
97
+ errs.join('; '))
98
+ ctx.out.spec = err
99
+ return err
100
+ }
101
+
102
+
103
+ // Inbound. PreDone rather than PreResult: the records are extracted from
104
+ // the response body by `makeResult`, which runs between the two, so at
105
+ // PreResult there is nothing to check but the envelope.
106
+ //
107
+ // HOOK ORDER MATTERS HERE, and the default order is not the one you want.
108
+ // PreDone hooks fire in feature ADD order, which defaults to `test` first
109
+ // and then names sorted — and `validate` sorts last, after `audit`,
110
+ // `cost`, `debug`, `metrics` and `telemetry`. Those observers therefore
111
+ // record the operation as a success before this hook has looked at it.
112
+ // Activating features as an ORDERED ARRAY fixes it:
113
+ //
114
+ // feature: [{ name: 'validate', active: true, response: true },
115
+ // { name: 'metrics', active: true }]
116
+ //
117
+ // What this feature can fix from here, it does: the result is marked
118
+ // failed and its records are cleared, so the entity absorbs nothing.
119
+ PreDone(this: any, ctx: any) {
120
+ if (!this.active || !this._response) {
121
+ return
122
+ }
123
+
124
+ const spec = this._entitySpec(ctx)
125
+ if (null == spec || null == spec.data) {
126
+ return
127
+ }
128
+
129
+ const resdata = ctx.result && ctx.result.resdata
130
+ if (null == resdata) {
131
+ return
132
+ }
133
+
134
+ // A list op returns many records and a load returns one; both are checked
135
+ // against the same record spec, because they are the same entity.
136
+ const records = Array.isArray(resdata) ? resdata : [resdata]
137
+ const errs: string[] = []
138
+ for (const record of records) {
139
+ if (null == record) {
140
+ continue
141
+ }
142
+
143
+ // A NON-OBJECT IS A FAILURE, not something to skip. A load that
144
+ // answered `42` where the entity's spec wants a record used to pass
145
+ // this feature silently, which is the one outcome a validator must
146
+ // never produce. struct rejects it with the field it could not find.
147
+ for (const e of this._check(ctx, unwrap(record), spec.data, 'response')) {
148
+ errs.push(e)
149
+ }
150
+ }
151
+
152
+ if (0 === errs.length || 'report' === this._mode) {
153
+ return
154
+ }
155
+
156
+ const err = ctx.error('validate_failed',
157
+ 'Invalid response for entity "' + entname(ctx) + '": ' + errs.join('; '))
158
+
159
+ // BOTH, and `ok` is the load-bearing half: `done` returns `resdata`
160
+ // whenever `result.ok` is true and never looks at `err`, so setting the
161
+ // error alone handed the caller the very records that failed the spec.
162
+ ctx.result.ok = false
163
+ ctx.result.err = err
164
+
165
+ // AND THE DATA GOES. The load/update fragments copy `result.resdata`
166
+ // into the entity's own state on any non-null value, BEFORE `done`
167
+ // raises — so rejecting the operation while leaving the records in place
168
+ // left the caller holding an entity populated from a payload this
169
+ // feature had just declared invalid. Clearing it is the only half of
170
+ // that this feature owns; see the note on hook order below.
171
+ ctx.result.resdata = undefined
172
+
173
+ return err
174
+ }
175
+
176
+
177
+ // The payload an operation is about to send.
178
+ //
179
+ // TWO SLOTS, AND THE OP PICKS. A body op (create/update/patch) carries the
180
+ // caller's argument in `reqdata` over the entity's `data`; a match op
181
+ // (load/list/remove) carries it in `reqmatch` over `match`. That is what
182
+ // the Entity*Op fragments pass to makeContext and what makePoint reads
183
+ // (`getprop(ctx, 'req' + op.input)`) — so reading `reqdata` for every op
184
+ // checked a `load({ id })` against the entity's STALE stored match and
185
+ // rejected it for the id the caller had just supplied.
186
+ _payload(this: any, ctx: any, opname: string): Record<string, any> {
187
+ const body = 'create' === opname || 'update' === opname || 'patch' === opname
188
+
189
+ const base = body ? ctx.data : ctx.match
190
+ const req = body ? ctx.reqdata : ctx.reqmatch
191
+
192
+ const out: Record<string, any> = { ...(base || {}), ...(req || {}) }
193
+
194
+ // `$action` SELECTS A CUSTOM ENDPOINT; it is not a field of the record.
195
+ // makePoint reads it off this same argument and the request transformer
196
+ // drops it before the body is built, so a spec built from the API's own
197
+ // fields will never name it — and under `strict` every custom-action
198
+ // call would be rejected for the one key that made it reachable.
199
+ delete out.$action
200
+
201
+ return out
202
+ }
203
+
204
+
205
+ _entitySpec(this: any, ctx: any): any {
206
+ return this._spec[entname(ctx)]
207
+ }
208
+
209
+
210
+ // One validate call. Errors are COLLECTED, never thrown: struct throws on
211
+ // the first failure unless given an `errs` array, and a caller fixing a
212
+ // payload wants every problem with it, not the first one.
213
+ _check(this: any, ctx: any, data: any, spec: any, direction: string): string[] {
214
+ const struct = ctx.utility.struct
215
+ const errs: string[] = []
216
+
217
+ try {
218
+ struct.validate(data, spec, { errs })
219
+ }
220
+ catch (e: any) {
221
+ // A spec this port cannot run at all (rather than a payload that fails
222
+ // it) must not take the operation down with it: report it like any
223
+ // other failure and let `mode` decide.
224
+ errs.push(e && e.message ? e.message : String(e))
225
+ }
226
+
227
+ if (0 < errs.length && 'function' === typeof this._options.onInvalid) {
228
+ try {
229
+ this._options.onInvalid({
230
+ entity: entname(ctx),
231
+ op: (ctx.op && ctx.op.name) || '',
232
+ direction,
233
+ errs,
234
+ data,
235
+ })
236
+ }
237
+ catch (_e) { }
238
+ }
239
+
240
+ return errs
241
+ }
242
+ }
243
+
244
+
245
+ // A RESULT RECORD AS DATA.
246
+ //
247
+ // `makeResult` turns every record of a LIST into an entity instance
248
+ // (`entity.make()` then `ent.data(entry)`), so what reaches PreDone for a
249
+ // list is wrappers, not records — and a wrapper checked against a field spec
250
+ // fails on every required field while its actual data goes unchecked. A load
251
+ // returns the record itself, so this has to handle both.
252
+ function unwrap(record: any): any {
253
+ if (null != record && 'function' === typeof record.data) {
254
+ const data = record.data()
255
+ if (null != data) {
256
+ return data
257
+ }
258
+ }
259
+ return record
260
+ }
261
+
262
+
263
+ function entname(ctx: any): string {
264
+ return (ctx.entity && ctx.entity.name) || (ctx.op && ctx.op.entity) || ''
265
+ }
266
+
267
+
268
+ // The spec tree with every `$OPEN` marker removed, so an undeclared key is an
269
+ // error rather than a pass. Rebuilt rather than mutated: ENTITYSPEC is a
270
+ // module constant shared by every client in the process.
271
+ function close(node: any): any {
272
+ if (Array.isArray(node)) {
273
+ return node.map((n: any) => close(n))
274
+ }
275
+
276
+ if (null == node || 'object' !== typeof node) {
277
+ return node
278
+ }
279
+
280
+ const out: Record<string, any> = {}
281
+ for (const key of Object.keys(node)) {
282
+ if (OPEN === key) {
283
+ continue
284
+ }
285
+ out[key] = close(node[key])
286
+ }
287
+
288
+ return out
289
+ }
290
+
291
+
292
+ // Built rather than written, so the backticks cannot be lost in an edit.
293
+ const OPEN = String.fromCharCode(96) + '$OPEN' + String.fromCharCode(96)
294
+
295
+
296
+ export {
297
+ ValidateFeature
298
+ }
@@ -1,5 +1,6 @@
1
1
 
2
2
  import { Context } from '../types'
3
+ import { OPTSPEC } from '../Schema'
3
4
 
4
5
 
5
6
  function makeOptions(ctx: Context) {
@@ -54,67 +55,19 @@ function makeOptions(ctx: Context) {
54
55
  let config = ctx.config || {}
55
56
  let cfgopts = config.options || {}
56
57
 
57
- // Standard SDK option values.
58
- const optspec = {
59
- apikey: '',
60
- secret: '',
61
- base: 'http://localhost:8000',
62
- prefix: '',
63
- suffix: '',
64
- auth: {
65
- prefix: '',
66
- basic: false
67
- },
68
- headers: {
69
- '`$CHILD`': '`$STRING`'
70
- },
71
- allow: {
72
- method: 'GET,PUT,POST,PATCH,DELETE,OPTIONS',
73
- op: 'create,update,load,list,remove,command,direct,graphql'
74
- },
75
- entity: {
76
- '`$CHILD`': {
77
- '`$OPEN`': true,
78
- active: false,
79
- alias: {}
80
- }
81
- },
82
- feature: {
83
- '`$CHILD`': {
84
- '`$OPEN`': true,
85
- active: false,
86
- }
87
- },
88
- utility: {},
89
- // Feature INSTANCES supplied at construction (the station adopt
90
- // path): consumed by the constructor's featureAdd loop, so they are
91
- // class instances, not data - `$ANY` accepts them verbatim. Without
92
- // this entry the seam is dead: the constructor reads
93
- // options.extend, but validate rejected the key.
94
- extend: '`$ANY`' as any,
95
- system: {
96
- fetch: undefined as any
97
- },
98
- test: {
99
- active: false,
100
- entity: {
101
- '`$OPEN`': true,
102
- }
103
- },
104
- clean: {
105
- keys: 'key,token,id'
106
- },
107
- // Server-variable values for a templated base URL (OpenAPI server
108
- // variables): `{name}` placeholders in `base` are substituted from
109
- // this map at construction. Spec defaults arrive via the generated
110
- // Config; user values override them.
111
- server: {
112
- '`$CHILD`': ''
113
- }
114
- }
115
-
116
- // JavaScript specific option values.
117
- optspec.system.fetch = opts.system?.fetch || global.fetch
58
+ // THE OPTION SPEC IS GENERATED, NOT WRITTEN HERE.
59
+ //
60
+ // `Spec.OPTSPEC` is built from the model: `main.kit.optspec` for the
61
+ // standard options, plus one entry per feature this target carries, taken
62
+ // from that feature's own `config.options` / `config.optspec`. Editing this
63
+ // file to add an option would put it back where it was — one of twenty
64
+ // hand-maintained copies of a schema nothing cross-checked — so add it to
65
+ // the model instead and every ported target validates it.
66
+ //
67
+ // NOT MUTATED. It is a module-level constant shared by every client this
68
+ // process constructs; the platform default below is applied to the RESULT,
69
+ // never to the spec.
70
+ const optspec = OPTSPEC
118
71
 
119
72
  // Clone the config side before merging: `config` is a module-level
120
73
  // singleton in ts/js, and merge would otherwise use its nested maps as
@@ -124,6 +77,16 @@ function makeOptions(ctx: Context) {
124
77
 
125
78
  opts = validate(opts, optspec)
126
79
 
80
+ // The platform fetch, supplied AFTER validate rather than as a spec
81
+ // default. `system.fetch` is declared `$ANY`, which passes a caller's own
82
+ // fetch through untouched but inserts nothing when the key is absent — and
83
+ // the spec is shared, so writing the default into it (as this did while the
84
+ // spec was a per-call literal) would hand one client's fetch to the next.
85
+ opts.system = opts.system || {}
86
+ if (null == opts.system.fetch) {
87
+ opts.system.fetch = global.fetch
88
+ }
89
+
127
90
  // Restore the suppression the optspec default would otherwise erase.
128
91
  if (authSuppressed) {
129
92
  opts.auth = null
@@ -377,16 +377,45 @@ describe('secrets', () => {
377
377
  // Before any op, nothing has been resolved.
378
378
  assert.equal(sdk.options().apikey, '')
379
379
 
380
- const name = names[0]
381
- const accessor = name.charAt(0).toUpperCase() + name.slice(1)
380
+ // AN OP THAT EXISTS, not `list` on whichever entity comes first.
381
+ //
382
+ // A generated TS entity carries only the ops its model DECLARES, so
383
+ // `list` is absent from an entity declaring `create` alone — and
384
+ // calling it throws before the PreSpec hook can run, failing the
385
+ // assertion below for a reason that has nothing to do with secrets.
386
+ // univec-sdk hit exactly that: its first entity declares `create`
387
+ // only, so this test failed on every run while PreSpec worked
388
+ // perfectly. Any real op exercises the hook; `list` is not special.
389
+ //
390
+ // This file is a TEMPLATE, so no project's op names are known here
391
+ // either. Discover the first callable one rather than assuming.
392
+ let ran = false
393
+ for (const one of names) {
394
+ const acc = one.charAt(0).toUpperCase() + one.slice(1)
395
+ const ent = (sdk as any)[acc]?.()
396
+ if (null == ent) {
397
+ continue
398
+ }
399
+ const opname = ['list', 'load', 'create']
400
+ .find((o: string) => 'function' === typeof (ent as any)[o])
401
+ if (null == opname) {
402
+ continue
403
+ }
404
+ // The op itself may fail (no seeded data, no live API) — irrelevant
405
+ // here. What matters is that the awaited PreSpec hook ran and the
406
+ // credential reached the live options before the spec was built.
407
+ try {
408
+ await (ent as any)[opname]({})
409
+ }
410
+ catch (_err) { }
411
+ ran = true
412
+ break
413
+ }
382
414
 
383
- // The op itself may fail (no seeded data, no live API) — irrelevant
384
- // here. What matters is that the awaited PreSpec hook ran and the
385
- // credential reached the live options before the spec was built.
386
- try {
387
- await sdk[accessor]().list()
415
+ if (!ran) {
416
+ // Entities, but not one callable op between them: no PreSpec path.
417
+ return
388
418
  }
389
- catch (_err) { }
390
419
 
391
420
  assert.equal(sdk.options().apikey, 'ENVKEY02',
392
421
  'the entity op did not resolve the secret through PreSpec')
@@ -3,7 +3,7 @@
3
3
  "package": 1
4
4
  },
5
5
  "name": "@voxgig/sdkgen",
6
- "version": "4.17.5",
6
+ "version": "4.18.0",
7
7
  "provides": {
8
8
  "target": [
9
9
  "c",
@@ -49,7 +49,8 @@
49
49
  "streaming",
50
50
  "telemetry",
51
51
  "test",
52
- "timeout"
52
+ "timeout",
53
+ "validate"
53
54
  ]
54
55
  }
55
56
  }
@@ -167,6 +167,36 @@ npm run generate
167
167
  Note: the \`voxgig-sdkgen\` CLI only *scaffolds* (\`target add\` /
168
168
  \`feature add\`). Generation itself runs via \`npm run generate\` (backed by
169
169
  \`@voxgig/model\`) — there is no \`generate\` CLI subcommand.
170
+
171
+ ### Two silent failure modes
172
+
173
+ Generation has two ways of going wrong that **nothing reports**. Neither
174
+ breaks a build or a test, so the only symptom is a tree that disagrees with
175
+ the model — which is easy to commit past.
176
+
177
+ **\`voxgig-model --no-config\` writes a REDUCED model.** The
178
+ \`.model-config\` build is what registers the generator actions, and an SDK
179
+ project loads \`apidef\` and \`sdkgen\` through exactly that mechanism:
180
+
181
+ \`\`\`
182
+ sys: model: action: { apidef: load: 'build/apidef.js', sdkgen: load: 'build/sdkgen.js' }
183
+ sys: model: order: action: 'apidef,sdkgen'
184
+ \`\`\`
185
+
186
+ \`--no-config\` skips it, so those actions never run — and the model build
187
+ still *writes* the model file, now missing whatever they contribute (the
188
+ name case variants, and whole subtrees). A reduced model is a valid model,
189
+ so nothing downstream complains. To inspect the model layer **without side
190
+ effects**, use \`npm run dry-generate\` (\`-y\`, writes nothing). Never
191
+ \`--no-config\` in anything whose output might be committed.
192
+
193
+ **Regeneration never DELETES.** A file the generator has stopped emitting
194
+ stays in the tree, and \`git status\` is silent because it is committed and
195
+ unchanged. Narrowing a feature's plugin selection, or dropping a target, can
196
+ leave whole modules behind that nothing references and no test covers.
197
+
198
+ To find either, the target trees must be deleted and regenerated — a
199
+ regeneration in place cannot see stale output at all.
170
200
  `
171
201
  }
172
202
 
@@ -20,6 +20,13 @@ type FeatureDoc = {
20
20
  transport: string
21
21
  wraps: boolean
22
22
  options: Array<{ name: string, value: string }>
23
+
24
+ // Options the feature accepts but does not default: callbacks, injected
25
+ // clocks, values the caller either supplies or does not. Declared in
26
+ // `config.optspec` because a DEFAULTS map cannot describe an option that
27
+ // has no default — which is why the tables built from `config.options`
28
+ // alone were incomplete, and had to say so.
29
+ extras: Array<{ name: string, type: string }>
23
30
  }
24
31
 
25
32
 
@@ -33,6 +40,27 @@ function isWrapping(feat: any): boolean {
33
40
  }
34
41
 
35
42
 
43
+ // A struct.validate sentinel as a reader's word for it: '`$FUNCTION`' ->
44
+ // 'function'. A union renders its members, so netsim's latency reads
45
+ // 'number | map'. Anything unrecognised renders verbatim rather than being
46
+ // dropped — a doc table that silently omits an option is the failure this
47
+ // whole path exists to fix.
48
+ function sentinelName(v: any): string {
49
+ if (Array.isArray(v)) {
50
+ const members = v.slice(1).map((m: any) => sentinelName(m))
51
+ .filter((m: string) => 'one' !== m)
52
+ return 0 === members.length ? 'any' : members.join(' | ')
53
+ }
54
+
55
+ if ('string' !== typeof v) {
56
+ return 'any'
57
+ }
58
+
59
+ const bare = v.replace(/[`$]/g, '').trim().toLowerCase()
60
+ return '' === bare ? 'any' : bare
61
+ }
62
+
63
+
36
64
  function renderValue(v: any): string {
37
65
  if (null == v) { return '' }
38
66
  if (Array.isArray(v)) { return '[' + v.map((x) => renderValue(x)).join(', ') + ']' }
@@ -63,6 +91,15 @@ function featureDocs(model: any, target?: any): FeatureDoc[] {
63
91
  name: k,
64
92
  value: renderValue(opts[k]),
65
93
  }))
94
+
95
+ const extra = (f.config && f.config.optspec) || {}
96
+ const extras = Object.keys(extra)
97
+ // A name in both is documented by its DEFAULT; `config.optspec`
98
+ // only sharpened its type (netsim's `latency`, a number that is
99
+ // also a { min, max } map), and a reader wants the default.
100
+ .filter((k) => null == opts[k])
101
+ .sort()
102
+ .map((k) => ({ name: k, type: sentinelName(extra[k]) }))
66
103
  return {
67
104
  name: f.name,
68
105
  Name: f.Name || f.name,
@@ -70,6 +107,7 @@ function featureDocs(model: any, target?: any): FeatureDoc[] {
70
107
  transport: f.transport || 'none',
71
108
  wraps: isWrapping(f),
72
109
  options,
110
+ extras,
73
111
  }
74
112
  })
75
113
  .sort((a: FeatureDoc, b: FeatureDoc) => a.name.localeCompare(b.name))
@@ -100,6 +138,7 @@ function honoursActivationOrder(target: any): boolean {
100
138
  export {
101
139
  featureDocs,
102
140
  renderValue,
141
+ sentinelName,
103
142
  honoursActivationOrder,
104
143
  }
105
144
 
@@ -78,13 +78,14 @@ ${f.title}.
78
78
 
79
79
  `)
80
80
 
81
- // THE TABLE IS THE MODEL'S OPTIONS, AND THE MODEL IS INCOMPLETE. Several
82
- // features accept runtime-only options the model never declares cost
83
- // reads `actor` and `sink`, audit reads `sink`so a table derived from
84
- // `config.options` cannot list them. Deriving from a second hand-written
85
- // list would reintroduce exactly the drift this module exists to avoid,
86
- // so the honest fix is upstream: declare them in the feature model. Until
87
- // then, say so rather than imply the table is exhaustive.
81
+ // THE TABLE IS THE MODEL'S OPTIONS, and the model now carries both
82
+ // halves of them. It used to carry one: `config.options` is a DEFAULTS
83
+ // map, so the options with no defaultcost's `sink`, audit's `sink`,
84
+ // every injected clock could not appear in it, and this table had to
85
+ // admit it was not exhaustive. `config.optspec` declares those with a
86
+ // type instead of a default, and they are listed below the defaults.
87
+ // Same declaration the generated option spec validates against, so a
88
+ // documented option and a validated option cannot be different sets.
88
89
 
89
90
 
90
91
  if (0 < f.options.length) {
@@ -98,18 +99,28 @@ ${f.title}.
98
99
  Content(`
99
100
  `)
100
101
  }
101
- else {
102
+ else if (0 === f.extras.length) {
102
103
  Content(`\`active\` only — this feature takes no further options.
103
104
 
104
105
  `)
105
106
  }
106
107
 
107
- Content(`Options above are those the model carries a default for. A feature may
108
- also accept callback options — a \`sink\` to receive each record, for
109
- instance — which have no default and are covered in the full feature
110
- reference.
108
+ if (0 < f.extras.length) {
109
+ Content(`| Option | Type |
110
+ |---|---|
111
+ `)
112
+ for (const o of f.extras) {
113
+ Content(`| \`${o.name}\` | ${o.type} |
114
+ `)
115
+ }
116
+ Content(`
117
+ These take no default: the feature behaves one way when you supply them and
118
+ another when you do not.
119
+
120
+ `)
121
+ }
111
122
 
112
- **Usage**
123
+ Content(`**Usage**
113
124
 
114
125
  Set \`feature.${f.name}.active\` to true in the client options${
115
126
  0 < f.options.length ?
@@ -105,6 +105,14 @@ function targetFeatures(model: any, target: any): Record<string, any> {
105
105
  const TAGS = [
106
106
  // A vendored sekreto port lives in this target's feature container.
107
107
  'sekreto',
108
+
109
+ // The target emits the generated `Schema` module: the model's option spec
110
+ // and, when a feature asks for them, per-entity struct.validate specs.
111
+ // `validate` needs this, and the porting order is the reason it is a tag
112
+ // rather than an assumption — every target vendors a struct with
113
+ // `validate` in it, but only a target whose Main emits Schema has
114
+ // anything for that function to check against.
115
+ 'schema',
108
116
  ]
109
117
 
110
118