@voxgig/sdkgen 4.2.5 → 4.2.6

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.
package/bin/voxgig-sdkgen CHANGED
@@ -8,7 +8,7 @@ const { Shape, One, Skip } = require('shape')
8
8
 
9
9
  const { SdkGen } = require('../dist/sdkgen.js')
10
10
 
11
- const VERSION = '4.2.5'
11
+ const VERSION = '4.2.6'
12
12
  const KONSOLE = console
13
13
 
14
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voxgig/sdkgen",
3
- "version": "4.2.5",
3
+ "version": "4.2.6",
4
4
  "main": "dist/sdkgen.js",
5
5
  "type": "commonjs",
6
6
  "engines": {
@@ -7,6 +7,7 @@ import {
7
7
  canonKey,
8
8
  canonScalarKey,
9
9
  each,
10
+ opParams,
10
11
  exampleVarName,
11
12
  names,
12
13
  } from '@voxgig/sdkgen'
@@ -36,13 +37,25 @@ function projectPath(suffix?: string): string {
36
37
  // has no params and the request shape mirrors the entity fields). Returns
37
38
  // undefined when neither is present.
38
39
  function paramCanonType(entity: any, op: any, paramName: string): unknown {
39
- const points = op && op.points ? each(op.points) : []
40
- for (const pt of points as any[]) {
41
- const params = pt && pt.args && pt.args.params ? each(pt.args.params) : []
42
- const found = (params as any[]).find((p: any) => p && p.name === paramName)
43
- if (found) {
44
- return found.type
45
- }
40
+ // opParams, NOT a raw walk of op.points.
41
+ //
42
+ // opParams drops points flagged with select['$action'] and merges what is
43
+ // left; the typed-model generator reaches the op's params through it, so
44
+ // anything else is a DIFFERENT set of params wearing the same name.
45
+ //
46
+ // A raw walk returned the first match on ANY point, action points included.
47
+ // github's `action` entity has a field `owner` (`$OBJECT`, "A GitHub user")
48
+ // and seven create points whose path carries `{owner}` (a string) — so the
49
+ // type said Record<string, any> and the doc example said 'example_owner',
50
+ // and ts/README.md stopped compiling. Same for workflow_id: `number` in the
51
+ // type, quoted string in the example.
52
+ //
53
+ // Deriving from opParams is what actually makes good on the promise below —
54
+ // that the docs and the generated types cannot disagree.
55
+ const params = op ? each(opParams(op)) : []
56
+ const found = (params as any[]).find((p: any) => p && p.name === paramName)
57
+ if (found) {
58
+ return found.type
46
59
  }
47
60
  const field = (entity && entity.fields ? each(entity.fields) : [])
48
61
  .find((f: any) => f && f.name === paramName) as any
@@ -6,6 +6,7 @@ import {
6
6
  canonKey,
7
7
  canonScalarKey,
8
8
  each,
9
+ opParams,
9
10
  } from '@voxgig/sdkgen'
10
11
 
11
12
  import {
@@ -28,13 +29,25 @@ import {
28
29
  // does. Falls back to the entity field of the same name (used when the op
29
30
  // has no params). Returns undefined when neither is present.
30
31
  function paramCanonType(entity: any, op: any, paramName: string): unknown {
31
- const points = op && op.points ? each(op.points) : []
32
- for (const pt of points as any[]) {
33
- const params = pt && pt.args && pt.args.params ? each(pt.args.params) : []
34
- const found = (params as any[]).find((p: any) => p && p.name === paramName)
35
- if (found) {
36
- return found.type
37
- }
32
+ // opParams, NOT a raw walk of op.points.
33
+ //
34
+ // opParams drops points flagged with select['$action'] and merges what is
35
+ // left; the typed-model generator reaches the op's params through it, so
36
+ // anything else is a DIFFERENT set of params wearing the same name.
37
+ //
38
+ // A raw walk returned the first match on ANY point, action points included.
39
+ // github's `action` entity has a field `owner` (`$OBJECT`, "A GitHub user")
40
+ // and seven create points whose path carries `{owner}` (a string) — so the
41
+ // type said Record<string, any> and the doc example said 'example_owner',
42
+ // and ts/README.md stopped compiling. Same for workflow_id: `number` in the
43
+ // type, quoted string in the example.
44
+ //
45
+ // Deriving from opParams is what actually makes good on the promise below —
46
+ // that the docs and the generated types cannot disagree.
47
+ const params = op ? each(opParams(op)) : []
48
+ const found = (params as any[]).find((p: any) => p && p.name === paramName)
49
+ if (found) {
50
+ return found.type
38
51
  }
39
52
  const field = (entity && entity.fields ? each(entity.fields) : [])
40
53
  .find((f: any) => f && f.name === paramName) as any
@@ -110,7 +110,16 @@ ${entityLines}
110
110
 
111
111
  // PHP-level errors that indicate a real bug in a documented example (as
112
112
  // opposed to an expected not-found / domain error, which is tolerated).
113
- private const FATAL = '/(Call to undefined method|Call to undefined function|Call to a member function|ArgumentCountError|Too few arguments|Undefined constant|Uncaught TypeError)/';
113
+ // A whitelist, so an expected domain error ("404: Not found") that an
114
+ // example deliberately catches stays tolerated.
115
+ //
116
+ // ParseError and "Parse error" are in it because runnable blocks are no
117
+ // longer linted: test_php_snippets_have_valid_syntax now skips anything the
118
+ // run pass executes, on the grounds that running a block proves it parses.
119
+ // That only holds if the run pass can SEE a parse failure — and without
120
+ // these two patterns it could not, so a syntax error in a runnable example
121
+ // would have passed both gates.
122
+ private const FATAL = '/(Call to undefined method|Call to undefined function|Call to a member function|ArgumentCountError|Too few arguments|Undefined constant|Uncaught TypeError|ParseError|Parse error)/';
114
123
 
115
124
  // The three documentation sources this gate covers.
116
125
  private function docs(): array
@@ -228,11 +237,25 @@ ${entityLines}
228
237
  $this->assertNotEmpty($this->phpBlocks(), 'docs should contain php examples');
229
238
  }
230
239
 
231
- /** Every php block across all three docs must parse (php -l). */
240
+ /**
241
+ * Every php block that is NOT executed must parse (php -l).
242
+ *
243
+ * Runnable blocks are deliberately excluded: test_php_examples_run_offline
244
+ * executes them, and a syntax error there fails that gate with a better
245
+ * message than a lint pass gives. Linting them as well spawned a second php
246
+ * process per runnable block for a result the run already establishes.
247
+ *
248
+ * The non-runnable blocks — illustrations and signature snippets — are
249
+ * never executed, so this is the ONLY syntax check they get, and dropping
250
+ * it entirely would lose that coverage.
251
+ */
232
252
  public function test_php_snippets_have_valid_syntax(): void
233
253
  {
234
254
  $failures = [];
235
255
  foreach ($this->phpBlocks() as $blk) {
256
+ if ($this->isRunnable($blk['code'])) {
257
+ continue;
258
+ }
236
259
  $block = $blk['code'];
237
260
  $code = preg_match('/^\\s*<\\?php/', $block) ? $block : "<?php\\n" . $block;
238
261
  $tmp = tempnam(sys_get_temp_dir(), 'readme_php_') . '.php';
@@ -312,27 +335,136 @@ ${entityLines}
312
335
  $ran = 0;
313
336
  $failures = [];
314
337
  $sdk = __DIR__ . '/../${sdkfile}';
338
+
339
+ // BATCHED. One php process for every runnable snippet, not one each.
340
+ // A repo with 277 entities documents hundreds of examples, and a
341
+ // process spawn per snippet dominated the php suite's wall clock.
342
+ //
343
+ // Each snippet is included inside a closure, so it gets its own
344
+ // variable scope, and is bracketed by markers so output is attributed
345
+ // back to the snippet that produced it. The interpreter is SHARED,
346
+ // which is the cost of batching: a snippet that mutates process-wide
347
+ // state (the SDK config singleton) or calls exit() affects what
348
+ // follows it.
349
+ //
350
+ // That is why the batch is not trusted blindly. A snippet whose END
351
+ // marker never arrives — killed by a fatal, or by an exit() that took
352
+ // the rest of the batch with it — is RE-RUN on its own, in a fresh
353
+ // process, and that isolated result is the one that counts. So the
354
+ // fast path is a single spawn, and the moment isolation actually
355
+ // matters the harness falls back to the old one-process-per-snippet
356
+ // behaviour for the affected snippets only.
357
+ $runnable = [];
315
358
  foreach ($this->phpBlocks() as $blk) {
316
- if (!$this->isRunnable($blk['code'])) {
317
- continue;
359
+ if ($this->isRunnable($blk['code'])) {
360
+ $runnable[] = $blk;
361
+ }
362
+ }
363
+ $ran = count($runnable);
364
+
365
+ if ($ran > 0) {
366
+ $dir = sys_get_temp_dir() . '/readme_batch_' . getmypid() . '_' . bin2hex(random_bytes(4));
367
+ @mkdir($dir, 0700, true);
368
+ $paths = [];
369
+ foreach ($runnable as $i => $blk) {
370
+ $f = $dir . '/snip_' . $i . '.php';
371
+ file_put_contents($f, $this->toRunner($blk['code'], $sdk));
372
+ $paths[$i] = $f;
318
373
  }
319
- $ran++;
320
- $runner = $this->toRunner($blk['code'], $sdk);
321
- $tmp = tempnam(sys_get_temp_dir(), 'readme_run_') . '.php';
322
- file_put_contents($tmp, $runner);
374
+
375
+ $driver = $dir . '/_driver.php';
376
+ file_put_contents($driver, $this->batchDriver($paths));
323
377
  $out = [];
324
378
  $rc = 0;
325
- exec('php ' . escapeshellarg($tmp) . ' 2>&1', $out, $rc);
326
- @unlink($tmp);
379
+ exec('php ' . escapeshellarg($driver) . ' 2>&1', $out, $rc);
327
380
  $text = implode("\\n", $out);
328
- if (preg_match(self::FATAL, $text) === 1) {
329
- $failures[] = $blk['doc'] . ' #' . $blk['n'] . ' (exit ' . $rc . "):\\n" . $text . "\\n" . $blk['code'];
381
+
382
+ foreach ($runnable as $i => $blk) {
383
+ $seg = $this->batchSegment($text, $i);
384
+ if ($seg === null) {
385
+ // No END marker: the batch died inside or before this
386
+ // snippet. Re-run it alone so the verdict is isolated.
387
+ $solo = [];
388
+ $src = 0;
389
+ exec('php ' . escapeshellarg($paths[$i]) . ' 2>&1', $solo, $src);
390
+ $seg = implode("\\n", $solo);
391
+ $rc = $src;
392
+ }
393
+ if (preg_match(self::FATAL, $seg) === 1) {
394
+ $failures[] = $blk['doc'] . ' #' . $blk['n'] . ' (exit ' . $rc . "):\\n" . $seg . "\\n" . $blk['code'];
395
+ }
330
396
  }
397
+
398
+ foreach ($paths as $f) { @unlink($f); }
399
+ @unlink($driver);
400
+ @rmdir($dir);
331
401
  }
332
402
  $this->assertGreaterThan(0, $ran, 'expected at least one runnable example to execute');
333
403
  $this->assertSame([], $failures, "docs php examples raised a real error when run offline:\\n" . implode("\\n\\n", $failures));
334
404
  }
335
405
 
406
+ /**
407
+ * The batch driver: include each snippet in its own closure scope, marked
408
+ * so output can be attributed back. The shutdown handler is what makes a
409
+ * fatal recoverable — it prints the END marker for whichever snippet was
410
+ * in flight, so the harness can tell "this one died" from "the batch
411
+ * stopped before reaching it", and re-run only what it must.
412
+ */
413
+ private function batchDriver(array $paths): string
414
+ {
415
+ // NOWDOC, not heredoc. The driver is PHP source that must survive
416
+ // being embedded verbatim: a heredoc interpolates it, and $e['message']
417
+ // with a quoted key is a parse error under interpolation. A nowdoc
418
+ // substitutes nothing, so the only thing injected is the file list.
419
+ $tpl = <<<'DRIVER'
420
+ <?php
421
+ $__files = __VOX_FILES__;
422
+ $__current = null;
423
+ register_shutdown_function(function () use (&$__current) {
424
+ if ($__current !== null) {
425
+ $e = error_get_last();
426
+ if ($e !== null) {
427
+ echo "\\nFATAL: " . $e['message'] . "\\n";
428
+ }
429
+ echo "\\n@@VOXEND " . $__current . "\\n";
430
+ }
431
+ });
432
+ foreach ($__files as $__i => $__f) {
433
+ $__current = $__i;
434
+ echo "\\n@@VOXBEGIN " . $__i . "\\n";
435
+ try {
436
+ (function ($__path) { include $__path; })($__f);
437
+ } catch (\\Throwable $__t) {
438
+ echo "\\nFATAL: " . get_class($__t) . ": " . $__t->getMessage() . "\\n";
439
+ }
440
+ echo "\\n@@VOXEND " . $__i . "\\n";
441
+ $__current = null;
442
+ }
443
+ DRIVER;
444
+ return str_replace('__VOX_FILES__', var_export($paths, true), $tpl);
445
+ }
446
+
447
+ /**
448
+ * The output between this snippet's BEGIN and END markers, or null when the
449
+ * END marker is absent — meaning the batch never got past it, and the
450
+ * caller must re-run it in isolation rather than guess.
451
+ */
452
+ private function batchSegment(string $text, int $i): ?string
453
+ {
454
+ $begin = '@@VOXBEGIN ' . $i;
455
+ $end = '@@VOXEND ' . $i;
456
+ $b = strpos($text, $begin);
457
+ if ($b === false) {
458
+ return null;
459
+ }
460
+ $b += strlen($begin);
461
+ $e = strpos($text, $end, $b);
462
+ if ($e === false) {
463
+ return null;
464
+ }
465
+ return trim(substr($text, $b, $e - $b));
466
+ }
467
+
336
468
  /**
337
469
  * COMPLETENESS GATE: every fenced php block is partitioned into exactly one
338
470
  * of {executed, syntaxchecked-nonrunnable, illustration}. The three counts
@@ -484,6 +484,80 @@ def _is_not_found_error(last):
484
484
  return ("404" in last) or ("not found" in low) or ("notfound" in low)
485
485
 
486
486
 
487
+ def _run_batch(items, label):
488
+ # BATCHED. One interpreter for every runnable block in this document,
489
+ # instead of one process each. A 269-entity SDK documents hundreds of
490
+ # examples and the spawn cost dominated the suite.
491
+ #
492
+ # Each block is exec'd in a FRESH globals dict, so names do not leak
493
+ # between snippets, and is bracketed by markers so output is attributed
494
+ # back. The interpreter is shared, which is the cost: a snippet that
495
+ # mutates process-wide state or dies hard affects what follows.
496
+ #
497
+ # So the batch is not trusted blindly. Any snippet whose END marker is
498
+ # missing is re-run on its own by the caller, and that isolated verdict is
499
+ # the one that counts.
500
+ driver = (
501
+ "import sys, traceback" + _NL
502
+ + "_S = __VOX_SOURCES__" + _NL
503
+ + "for _i, _src in _S:" + _NL
504
+ + " print('@@VOXBEGIN ' + str(_i), flush=True)" + _NL
505
+ + " try:" + _NL
506
+ + " _g = {'__name__': '__main__'}" + _NL
507
+ + " exec(compile(_src, '<snippet>', 'exec'), _g)" + _NL
508
+ + " except BaseException:" + _NL
509
+ + " traceback.print_exc(file=sys.stdout)" + _NL
510
+ + " print('@@VOXEND ' + str(_i), flush=True)" + _NL
511
+ )
512
+ driver = driver.replace("__VOX_SOURCES__", repr(items))
513
+
514
+ env = dict(os.environ)
515
+ env["PYTHONDONTWRITEBYTECODE"] = "1"
516
+ env["PYTHONPATH"] = _PY_ROOT + os.pathsep + env.get("PYTHONPATH", "")
517
+ proc = subprocess.run(
518
+ [sys.executable, "-c", driver],
519
+ cwd=_PY_ROOT, env=env, capture_output=True, text=True,
520
+ )
521
+ return (proc.stdout or "") + (proc.stderr or "")
522
+
523
+
524
+ def _batch_segment(text, index):
525
+ # The output between this snippet's markers, or None when the END marker
526
+ # never arrived — the batch died in or before it, so only an isolated
527
+ # re-run can say what happened.
528
+ begin = "@@VOXBEGIN " + str(index)
529
+ end = "@@VOXEND " + str(index)
530
+ b = text.find(begin)
531
+ if b < 0:
532
+ return None
533
+ b += len(begin)
534
+ e = text.find(end, b)
535
+ if e < 0:
536
+ return None
537
+ return text[b:e].strip()
538
+
539
+
540
+ def _classify_batch_error(seg, source, label, index):
541
+ # Same verdict as _run_source, read off a traceback in the batch output.
542
+ if not seg:
543
+ return None
544
+ errlines = [ln for ln in seg.split(_NL) if ln.strip()]
545
+ if not errlines or "Traceback (most recent call last)" not in seg:
546
+ return None
547
+ last = errlines[-1]
548
+ exc_type = last.split(":", 1)[0].strip().split(".")[-1]
549
+ detail = (
550
+ label + " python block #" + str(index) + ": " + last + _NL + _NL
551
+ + "--- executed source ---" + _NL + source
552
+ + _NL + _NL + "--- batch output ---" + _NL + seg
553
+ )
554
+ if exc_type in _PROGRAMMING_ERROR_NAMES:
555
+ return "PROGRAMMING ERROR in " + detail
556
+ if _is_not_found_error(last):
557
+ return None
558
+ return "RUNTIME ERROR in " + detail
559
+
560
+
487
561
  def _run_source(source, label, index):
488
562
  # Run one rewritten block in a subprocess. Returns None when it exits 0 or
489
563
  # fails with the single tolerated not-found/404 domain error; otherwise
@@ -536,6 +610,7 @@ def _completeness_gate(label, blocks):
536
610
  illustration = 0
537
611
  compiled = 0
538
612
  failures = []
613
+ _pending = []
539
614
 
540
615
  for i, block in enumerate(blocks):
541
616
  kind = _classify(block)
@@ -553,14 +628,24 @@ def _completeness_gate(label, blocks):
553
628
  )
554
629
  continue
555
630
  executed += 1
556
- msg = _run_source(source, label, i)
557
- if msg is not None:
558
- failures.append(msg)
631
+ _pending.append((i, source))
559
632
  elif kind == "illustration":
560
633
  illustration += 1
561
634
  else:
562
635
  compiled += 1
563
636
 
637
+ if _pending:
638
+ _text = _run_batch(_pending, label)
639
+ for _i, _src in _pending:
640
+ _seg = _batch_segment(_text, _i)
641
+ if _seg is None:
642
+ # Batch never finished this one — isolate it.
643
+ _msg = _run_source(_src, label, _i)
644
+ else:
645
+ _msg = _classify_batch_error(_seg, _src, label, _i)
646
+ if _msg is not None:
647
+ failures.append(_msg)
648
+
564
649
  print(
565
650
  _NL + "[readme-examples] " + label + " python blocks: total="
566
651
  + str(total) + " executed=" + str(executed) + " compiled="
@@ -82,6 +82,7 @@ const ReadmeExamplesTest = cmp(function ReadmeExamplesTest(props: any) {
82
82
  require "minitest/autorun"
83
83
  require "tempfile"
84
84
  require "open3"
85
+ require "tmpdir"
85
86
 
86
87
  class ReadmeExamplesTest < Minitest::Test
87
88
  # The three documentation sources this gate covers.
@@ -227,17 +228,46 @@ ${entityLines}
227
228
  # domain errors (e.g. "404: Not found") never match FATAL, so caught not-found
228
229
  # cases stay tolerated.
229
230
  def test_ruby_examples_run_offline
230
- ran = 0
231
231
  failures = []
232
- ruby_blocks.each do |blk|
233
- next unless runnable?(blk[:code])
234
- ran += 1
235
- Tempfile.create(["readme_run_", ".rb"]) do |f|
236
- f.write(to_runner(blk[:code]))
237
- f.flush
238
- out, status = Open3.capture2e("ruby", f.path)
239
- if out =~ FATAL
240
- failures << "#{blk[:doc]} ##{blk[:n]} (exit #{status.exitstatus}):\\n#{out}\\n#{blk[:code]}"
232
+ runnable = ruby_blocks.select { |blk| runnable?(blk[:code]) }
233
+ ran = runnable.length
234
+
235
+ # BATCHED. One ruby process for every runnable block, not one each. A
236
+ # spawn per snippet dominated this suite on the large SDKs.
237
+ #
238
+ # Each snippet is loaded inside its own anonymous Module, so constants and
239
+ # methods do not collide, and is bracketed by markers so output is
240
+ # attributed back. The interpreter is SHARED, which is the cost of
241
+ # batching: a snippet that mutates global state or calls exit! affects
242
+ # what follows it.
243
+ #
244
+ # So the batch is not trusted blindly. Any snippet whose END marker never
245
+ # arrives is re-run on its own, in a fresh process, and that isolated
246
+ # result is the one that counts.
247
+ if ran > 0
248
+ Dir.mktmpdir("readme_batch_") do |dir|
249
+ paths = {}
250
+ runnable.each_with_index do |blk, i|
251
+ path = File.join(dir, "snip_#{i}.rb")
252
+ File.write(path, to_runner(blk[:code]))
253
+ paths[i] = path
254
+ end
255
+
256
+ driver = File.join(dir, "_driver.rb")
257
+ File.write(driver, batch_driver(paths))
258
+ out, status = Open3.capture2e("ruby", driver)
259
+
260
+ runnable.each_with_index do |blk, i|
261
+ seg = batch_segment(out, i)
262
+ code = status.exitstatus
263
+ if seg.nil?
264
+ solo, sstatus = Open3.capture2e("ruby", paths[i])
265
+ seg = solo
266
+ code = sstatus.exitstatus
267
+ end
268
+ if seg =~ FATAL
269
+ failures << "#{blk[:doc]} ##{blk[:n]} (exit #{code}):\\n#{seg}\\n#{blk[:code]}"
270
+ end
241
271
  end
242
272
  end
243
273
  end
@@ -245,6 +275,49 @@ ${entityLines}
245
275
  assert_equal [], failures, "docs ruby examples raised a real error when run offline:\\n#{failures.join("\\n\\n")}"
246
276
  end
247
277
 
278
+ # The batch driver: load each snippet into its own anonymous Module so its
279
+ # constants and methods cannot collide with another's. The at_exit hook is
280
+ # what makes a hard death recoverable — it emits the END marker for whichever
281
+ # snippet was in flight, so the harness can tell "this one died" from "the
282
+ # batch never reached it", and re-run only what it must.
283
+ def batch_driver(paths)
284
+ list = paths.keys.sort.map { |i| [i, paths[i]] }
285
+ <<~DRIVER
286
+ files = #{list.inspect}
287
+ current = nil
288
+ at_exit do
289
+ if current
290
+ puts
291
+ puts "@@VOXEND \\#{current}"
292
+ end
293
+ end
294
+ files.each do |(i, path)|
295
+ current = i
296
+ puts
297
+ puts "@@VOXBEGIN \\#{i}"
298
+ begin
299
+ Module.new.module_eval(File.read(path), path)
300
+ rescue Exception => e
301
+ puts "FATAL: \\#{e.class}: \\#{e.message}"
302
+ end
303
+ puts
304
+ puts "@@VOXEND \\#{i}"
305
+ current = nil
306
+ end
307
+ DRIVER
308
+ end
309
+
310
+ # Output between this snippet's markers, or nil when the END marker is
311
+ # absent — the batch never got past it, so only an isolated re-run can say.
312
+ def batch_segment(text, i)
313
+ b = text.index("@@VOXBEGIN #{i}")
314
+ return nil if b.nil?
315
+ b += "@@VOXBEGIN #{i}".length
316
+ e = text.index("@@VOXEND #{i}", b)
317
+ return nil if e.nil?
318
+ text[b...e].strip
319
+ end
320
+
248
321
  # COMPLETENESS GATE: every fenced ruby block is partitioned into exactly one of
249
322
  # {executed, syntaxchecked-nonrunnable, illustration}. The three counts must
250
323
  # sum to the total. A block that references the SDK but is neither runnable (so
@@ -6,6 +6,7 @@ import {
6
6
  canonKey,
7
7
  canonScalarKey,
8
8
  each,
9
+ opParams,
9
10
  } from '@voxgig/sdkgen'
10
11
 
11
12
  import {
@@ -29,13 +30,25 @@ import {
29
30
  // has no params and the generated match type is `Partial<Entity>`). Returns
30
31
  // undefined when neither is present.
31
32
  function paramCanonType(entity: any, op: any, paramName: string): unknown {
32
- const points = op && op.points ? each(op.points) : []
33
- for (const pt of points as any[]) {
34
- const params = pt && pt.args && pt.args.params ? each(pt.args.params) : []
35
- const found = (params as any[]).find((p: any) => p && p.name === paramName)
36
- if (found) {
37
- return found.type
38
- }
33
+ // opParams, NOT a raw walk of op.points.
34
+ //
35
+ // opParams drops points flagged with select['$action'] and merges what is
36
+ // left; the typed-model generator reaches the op's params through it, so
37
+ // anything else is a DIFFERENT set of params wearing the same name.
38
+ //
39
+ // A raw walk returned the first match on ANY point, action points included.
40
+ // github's `action` entity has a field `owner` (`$OBJECT`, "A GitHub user")
41
+ // and seven create points whose path carries `{owner}` (a string) — so the
42
+ // type said Record<string, any> and the doc example said 'example_owner',
43
+ // and ts/README.md stopped compiling. Same for workflow_id: `number` in the
44
+ // type, quoted string in the example.
45
+ //
46
+ // Deriving from opParams is what actually makes good on the promise below —
47
+ // that the docs and the generated types cannot disagree.
48
+ const params = op ? each(opParams(op)) : []
49
+ const found = (params as any[]).find((p: any) => p && p.name === paramName)
50
+ if (found) {
51
+ return found.type
39
52
  }
40
53
  const field = (entity && entity.fields ? each(entity.fields) : [])
41
54
  .find((f: any) => f && f.name === paramName) as any
@@ -3,7 +3,7 @@
3
3
  "package": 1
4
4
  },
5
5
  "name": "@voxgig/sdkgen",
6
- "version": "4.2.5",
6
+ "version": "4.2.6",
7
7
  "provides": {
8
8
  "target": [
9
9
  "c",