@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 +1 -1
- package/package.json +1 -1
- package/project/.sdk/src/cmp/go/utility_go.ts +20 -7
- package/project/.sdk/src/cmp/js/utility_js.ts +20 -7
- package/project/.sdk/src/cmp/php/ReadmeExamplesTest_php.ts +144 -12
- package/project/.sdk/src/cmp/py/ReadmeExamplesTest_py.ts +88 -3
- package/project/.sdk/src/cmp/rb/ReadmeExamplesTest_rb.ts +83 -10
- package/project/.sdk/src/cmp/ts/utility_ts.ts +20 -7
- package/project/sdkgen-package.json +1 -1
package/bin/voxgig-sdkgen
CHANGED
package/package.json
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 (
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
$
|
|
321
|
-
$
|
|
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($
|
|
326
|
-
@unlink($tmp);
|
|
379
|
+
exec('php ' . escapeshellarg($driver) . ' 2>&1', $out, $rc);
|
|
327
380
|
$text = implode("\\n", $out);
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|