@sverka/playground 0.2.11 → 0.2.13
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/dist/index.d.mts +64 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1154 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -4
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Inflate, deflateSync } from "fflate";
|
|
2
|
+
import { generateSarifHtml } from "@sverka/sarif-viewer-web/html-generator";
|
|
2
3
|
//#region src/pipeline.ts
|
|
3
4
|
/** Base class for all constructs. */
|
|
4
5
|
var Construct = class {
|
|
@@ -269,6 +270,1158 @@ function decodeShareLink(hash) {
|
|
|
269
270
|
}
|
|
270
271
|
}
|
|
271
272
|
//#endregion
|
|
272
|
-
|
|
273
|
+
//#region src/scanner.ts
|
|
274
|
+
/** Non-ASCII whitespace code points beyond the Latin-1 range. */
|
|
275
|
+
const WS_POINTS = /* @__PURE__ */ new Set([
|
|
276
|
+
160,
|
|
277
|
+
5760,
|
|
278
|
+
8232,
|
|
279
|
+
8233,
|
|
280
|
+
8239,
|
|
281
|
+
8287,
|
|
282
|
+
12288,
|
|
283
|
+
65279
|
|
284
|
+
]);
|
|
285
|
+
/** Whitespace per the JS grammar: \t \n \v \f \r space, NBSP, the unicode
|
|
286
|
+
* space separators (0x2000–0x200a), and ZWNBSP. */
|
|
287
|
+
function isWsChar(c) {
|
|
288
|
+
if (c === void 0) return false;
|
|
289
|
+
const n = c.codePointAt(0) ?? 0;
|
|
290
|
+
if (n >= 9 && n <= 13 || n === 32) return true;
|
|
291
|
+
return n >= 8192 && n <= 8202 || WS_POINTS.has(n);
|
|
292
|
+
}
|
|
293
|
+
/** ASCII letter-or-digit — digits and both letter cases. */
|
|
294
|
+
function isAsciiLetterOrDigit(n) {
|
|
295
|
+
return n >= 48 && n <= 57 || n >= 65 && n <= 90 || n >= 97 && n <= 122;
|
|
296
|
+
}
|
|
297
|
+
/** Identifier-part chars, approximated: ASCII ident chars plus any
|
|
298
|
+
* non-ASCII char that isn't JS whitespace — `export` + a non-ASCII
|
|
299
|
+
* letter keeps its boundary. Over-approximation only ever suppresses
|
|
300
|
+
* a false keyword match — it never corrupts code. */
|
|
301
|
+
function isIdentChar(c) {
|
|
302
|
+
if (c === void 0) return false;
|
|
303
|
+
const n = c.codePointAt(0) ?? 0;
|
|
304
|
+
if (n >= 128) return !isWsChar(c);
|
|
305
|
+
return isAsciiLetterOrDigit(n) || c === "_" || c === "$";
|
|
306
|
+
}
|
|
307
|
+
function isQuote(c) {
|
|
308
|
+
return c === "\"" || c === "'" || c === "`";
|
|
309
|
+
}
|
|
310
|
+
function skipIdent(code, i) {
|
|
311
|
+
while (i < code.length && isIdentChar(code[i])) i++;
|
|
312
|
+
return i;
|
|
313
|
+
}
|
|
314
|
+
/** Index of the `\n` ending the `//` comment at `at` (code.length if none). */
|
|
315
|
+
function lineCommentEnd(code, at) {
|
|
316
|
+
const end = code.indexOf("\n", at + 2);
|
|
317
|
+
return end === -1 ? code.length : end;
|
|
318
|
+
}
|
|
319
|
+
/** Index just past the closing star-slash ending the block comment at
|
|
320
|
+
* `at` — the literal marker is never written inside this doc comment
|
|
321
|
+
* or it would terminate the comment itself. */
|
|
322
|
+
function blockCommentEnd(code, at) {
|
|
323
|
+
const end = code.indexOf("*/", at + 2);
|
|
324
|
+
return end === -1 ? code.length : end + 2;
|
|
325
|
+
}
|
|
326
|
+
/** True when `at` starts a `//` or `/*` comment. */
|
|
327
|
+
function commentAt(code, at) {
|
|
328
|
+
return code[at] === "/" && (code[at + 1] === "/" || code[at + 1] === "*");
|
|
329
|
+
}
|
|
330
|
+
/** True when `at` opens a string literal or a comment. */
|
|
331
|
+
function isOpaqueStart(code, at) {
|
|
332
|
+
return isQuote(code[at]) || commentAt(code, at);
|
|
333
|
+
}
|
|
334
|
+
/** End index of the string literal starting at `at` (quote char).
|
|
335
|
+
* Template literals may nest `${}` expressions — tracked by brace depth. */
|
|
336
|
+
function scanString(code, at) {
|
|
337
|
+
const quote = code[at];
|
|
338
|
+
let i = at + 1;
|
|
339
|
+
while (i < code.length) {
|
|
340
|
+
const c = code[i];
|
|
341
|
+
if (c === "\\") {
|
|
342
|
+
i += 2;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (c === quote) return i + 1;
|
|
346
|
+
if (quote === "`" && c === "$" && code[i + 1] === "{") {
|
|
347
|
+
i = scanTemplateExpr(code, i + 1);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
i++;
|
|
351
|
+
}
|
|
352
|
+
return i;
|
|
353
|
+
}
|
|
354
|
+
/** End index of a regex literal starting at `at` (`/`): closes at the
|
|
355
|
+
* first unescaped `/` outside a `[]` class, then consumes flags. Stops at
|
|
356
|
+
* a newline or EOF when unterminated — the evaluator reports the syntax. */
|
|
357
|
+
function scanRegex(code, at) {
|
|
358
|
+
let i = at + 1;
|
|
359
|
+
let inClass = false;
|
|
360
|
+
while (i < code.length) {
|
|
361
|
+
const c = code[i];
|
|
362
|
+
if (c === "\\") {
|
|
363
|
+
i += 2;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (c === "[") inClass = true;
|
|
367
|
+
else if (c === "]") inClass = false;
|
|
368
|
+
else if (c === "/" && !inClass) return skipIdent(code, i + 1);
|
|
369
|
+
else if (c === "\n") return i;
|
|
370
|
+
i++;
|
|
371
|
+
}
|
|
372
|
+
return i;
|
|
373
|
+
}
|
|
374
|
+
/** End index of the opaque region at `i`, or i+1 for ordinary chars —
|
|
375
|
+
* plus whether the region was a string literal (an operand). */
|
|
376
|
+
function scanOpaqueEnd(code, i) {
|
|
377
|
+
const c = code[i];
|
|
378
|
+
if (isQuote(c)) return {
|
|
379
|
+
end: scanString(code, i),
|
|
380
|
+
isString: true
|
|
381
|
+
};
|
|
382
|
+
if (c === "/" && code[i + 1] === "/") return {
|
|
383
|
+
end: lineCommentEnd(code, i),
|
|
384
|
+
isString: false
|
|
385
|
+
};
|
|
386
|
+
if (c === "/" && code[i + 1] === "*") return {
|
|
387
|
+
end: blockCommentEnd(code, i),
|
|
388
|
+
isString: false
|
|
389
|
+
};
|
|
390
|
+
return {
|
|
391
|
+
end: i + 1,
|
|
392
|
+
isString: false
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/** Keywords and contextual keywords after which `/` opens a regex rather
|
|
396
|
+
* than divides. Everything else — plain identifiers plus literals such
|
|
397
|
+
* as `this`/`true` — ends an operand, making `/` a division. */
|
|
398
|
+
const NON_OPERAND_WORDS = /* @__PURE__ */ new Set([
|
|
399
|
+
"await",
|
|
400
|
+
"break",
|
|
401
|
+
"case",
|
|
402
|
+
"catch",
|
|
403
|
+
"class",
|
|
404
|
+
"const",
|
|
405
|
+
"continue",
|
|
406
|
+
"debugger",
|
|
407
|
+
"default",
|
|
408
|
+
"delete",
|
|
409
|
+
"do",
|
|
410
|
+
"else",
|
|
411
|
+
"enum",
|
|
412
|
+
"export",
|
|
413
|
+
"extends",
|
|
414
|
+
"finally",
|
|
415
|
+
"for",
|
|
416
|
+
"function",
|
|
417
|
+
"if",
|
|
418
|
+
"implements",
|
|
419
|
+
"import",
|
|
420
|
+
"in",
|
|
421
|
+
"instanceof",
|
|
422
|
+
"interface",
|
|
423
|
+
"let",
|
|
424
|
+
"new",
|
|
425
|
+
"package",
|
|
426
|
+
"private",
|
|
427
|
+
"protected",
|
|
428
|
+
"public",
|
|
429
|
+
"return",
|
|
430
|
+
"static",
|
|
431
|
+
"switch",
|
|
432
|
+
"throw",
|
|
433
|
+
"try",
|
|
434
|
+
"typeof",
|
|
435
|
+
"var",
|
|
436
|
+
"void",
|
|
437
|
+
"while",
|
|
438
|
+
"with",
|
|
439
|
+
"yield",
|
|
440
|
+
"as",
|
|
441
|
+
"async",
|
|
442
|
+
"from",
|
|
443
|
+
"get",
|
|
444
|
+
"of",
|
|
445
|
+
"set",
|
|
446
|
+
"type",
|
|
447
|
+
"declare"
|
|
448
|
+
]);
|
|
449
|
+
/** True when `word` ends an operand — a following `/` is division. */
|
|
450
|
+
function operandAfterWord(word) {
|
|
451
|
+
return !NON_OPERAND_WORDS.has(word);
|
|
452
|
+
}
|
|
453
|
+
/** Consume one token at `i` for the brace-matching scans: opaque regions
|
|
454
|
+
* and regex literals advance wholesale, identifiers update operand
|
|
455
|
+
* state from the keyword table, braces report a depth delta. */
|
|
456
|
+
function scanToken(code, i, operandEnd) {
|
|
457
|
+
const opaque = scanOpaqueEnd(code, i);
|
|
458
|
+
if (opaque.end > i + 1) return {
|
|
459
|
+
end: opaque.end,
|
|
460
|
+
operandEnd: opaque.isString || operandEnd,
|
|
461
|
+
brace: 0
|
|
462
|
+
};
|
|
463
|
+
const ch = code[i];
|
|
464
|
+
if (ch === "/" && !operandEnd) return {
|
|
465
|
+
end: scanRegex(code, i),
|
|
466
|
+
operandEnd: true,
|
|
467
|
+
brace: 0
|
|
468
|
+
};
|
|
469
|
+
if (isWsChar(ch)) return {
|
|
470
|
+
end: i + 1,
|
|
471
|
+
operandEnd,
|
|
472
|
+
brace: 0
|
|
473
|
+
};
|
|
474
|
+
if (isIdentChar(ch)) {
|
|
475
|
+
const wend = skipIdent(code, i);
|
|
476
|
+
return {
|
|
477
|
+
end: wend,
|
|
478
|
+
operandEnd: operandAfterWord(code.slice(i, wend)),
|
|
479
|
+
brace: 0
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
if (ch === "{") return {
|
|
483
|
+
end: i + 1,
|
|
484
|
+
operandEnd: false,
|
|
485
|
+
brace: 1
|
|
486
|
+
};
|
|
487
|
+
if (ch === "}") return {
|
|
488
|
+
end: i + 1,
|
|
489
|
+
operandEnd: true,
|
|
490
|
+
brace: -1
|
|
491
|
+
};
|
|
492
|
+
return {
|
|
493
|
+
end: i + 1,
|
|
494
|
+
operandEnd: false,
|
|
495
|
+
brace: 0
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Scan one operand-context token — an opaque span, a regex-or-division,
|
|
500
|
+
* or an identifier run — returning where it ends and whether it leaves
|
|
501
|
+
* an operand behind. Comments are transparent (operand context passes
|
|
502
|
+
* through unchanged). Returns null for punctuation/whitespace the
|
|
503
|
+
* caller's own loop owns.
|
|
504
|
+
*/
|
|
505
|
+
function scanOperand(code, i, operandEnd) {
|
|
506
|
+
const opaque = scanOpaqueEnd(code, i);
|
|
507
|
+
if (opaque.end > i + 1) return {
|
|
508
|
+
end: opaque.end,
|
|
509
|
+
operandEnd: operandEnd || opaque.isString
|
|
510
|
+
};
|
|
511
|
+
const c = code[i];
|
|
512
|
+
if (c === "/") return operandEnd ? {
|
|
513
|
+
end: i + 1,
|
|
514
|
+
operandEnd: false
|
|
515
|
+
} : {
|
|
516
|
+
end: scanRegex(code, i),
|
|
517
|
+
operandEnd: true
|
|
518
|
+
};
|
|
519
|
+
if (isIdentChar(c)) {
|
|
520
|
+
const wend = skipIdent(code, i);
|
|
521
|
+
return {
|
|
522
|
+
end: wend,
|
|
523
|
+
operandEnd: operandAfterWord(code.slice(i, wend))
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* End index of the `${` expression whose `{` is at `at`. Strings,
|
|
530
|
+
* comments, nested templates, and regex literals inside the expression
|
|
531
|
+
* are all skipped correctly: `` `${ x["`"] }` `` and `` `${ /"/ }` ``
|
|
532
|
+
* must not treat literal text as syntax.
|
|
533
|
+
*/
|
|
534
|
+
function scanTemplateExpr(code, at) {
|
|
535
|
+
let depth = 1;
|
|
536
|
+
let i = at + 1;
|
|
537
|
+
let operandEnd = false;
|
|
538
|
+
while (i < code.length && depth > 0) {
|
|
539
|
+
const step = scanToken(code, i, operandEnd);
|
|
540
|
+
depth += step.brace;
|
|
541
|
+
operandEnd = step.operandEnd;
|
|
542
|
+
i = step.end;
|
|
543
|
+
}
|
|
544
|
+
return i;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Index of the `}` matching the `{` at `open`; -1 when unbalanced.
|
|
548
|
+
* Operand tracking decides whether a `/` divides or opens a regex —
|
|
549
|
+
* braces inside `/[{]/` never reach the depth counter.
|
|
550
|
+
*/
|
|
551
|
+
function matchBrace(code, open) {
|
|
552
|
+
let depth = 0;
|
|
553
|
+
let i = open;
|
|
554
|
+
let operandEnd = false;
|
|
555
|
+
while (i < code.length) {
|
|
556
|
+
const step = scanToken(code, i, operandEnd);
|
|
557
|
+
depth += step.brace;
|
|
558
|
+
operandEnd = step.operandEnd;
|
|
559
|
+
i = step.end;
|
|
560
|
+
if (step.brace === -1 && depth === 0) return i - 1;
|
|
561
|
+
}
|
|
562
|
+
return -1;
|
|
563
|
+
}
|
|
564
|
+
/** `word` starting at `at` with an identifier boundary on the right. */
|
|
565
|
+
function keywordAt(code, at, word) {
|
|
566
|
+
return code.startsWith(word, at) && !isIdentChar(code[at + word.length]);
|
|
567
|
+
}
|
|
568
|
+
/** Skip whitespace and comments (including newlines). */
|
|
569
|
+
function skipTrivia(code, at) {
|
|
570
|
+
let i = at;
|
|
571
|
+
for (;;) {
|
|
572
|
+
const c = code[i];
|
|
573
|
+
if (isWsChar(c)) {
|
|
574
|
+
i++;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (c === "/" && code[i + 1] === "/") {
|
|
578
|
+
i = lineCommentEnd(code, i);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (c === "/" && code[i + 1] === "*") {
|
|
582
|
+
i = blockCommentEnd(code, i);
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
return i;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
//#endregion
|
|
589
|
+
//#region src/engine.ts
|
|
590
|
+
/** Default template shown in the editor. */
|
|
591
|
+
const DEFAULT_CODE = [
|
|
592
|
+
"import { Project, Pipeline, FunctionStep, Entry } from \"@sverka/playground\";",
|
|
593
|
+
"",
|
|
594
|
+
"const proj = new Project(\"demo\");",
|
|
595
|
+
"const checks = new Pipeline(proj, \"checks\");",
|
|
596
|
+
"",
|
|
597
|
+
"new FunctionStep(checks, \"lint\", {",
|
|
598
|
+
" fn: () => [",
|
|
599
|
+
" { rule: \"no-unused-vars\", file: \"src/index.ts\", line: 5, severity: \"high\", message: \"Variable 'x' is declared but never used\" },",
|
|
600
|
+
" { rule: \"no-console\", file: \"src/utils.ts\", line: 12, severity: \"medium\", message: \"Unexpected console.log statement\" },",
|
|
601
|
+
" ],",
|
|
602
|
+
"});",
|
|
603
|
+
"",
|
|
604
|
+
"new FunctionStep(checks, \"typecheck\", {",
|
|
605
|
+
" fn: () => [",
|
|
606
|
+
" { rule: \"ts2322\", file: \"src/types.ts\", line: 8, severity: \"critical\", message: \"Type 'string' is not assignable to type 'number'\" },",
|
|
607
|
+
" ],",
|
|
608
|
+
"});",
|
|
609
|
+
"",
|
|
610
|
+
"new FunctionStep(checks, \"test\", {",
|
|
611
|
+
" fn: () => [",
|
|
612
|
+
" { rule: \"assertion-failed\", file: \"test/index.test.ts\", line: 23, severity: \"high\", message: \"Expected 5 but got 3\" },",
|
|
613
|
+
" { rule: \"assertion-failed\", file: \"test/index.test.ts\", line: 45, severity: \"low\", message: \"Expected 'hello' but got 'world'\" },",
|
|
614
|
+
" ],",
|
|
615
|
+
"});",
|
|
616
|
+
"",
|
|
617
|
+
"new Entry(checks, \"on-push\", { trigger: { kind: \"push\" }, roots: [\"test\"] });",
|
|
618
|
+
"",
|
|
619
|
+
"export default proj;",
|
|
620
|
+
""
|
|
621
|
+
].join("\n");
|
|
622
|
+
/** Escape HTML special characters to prevent XSS. */
|
|
623
|
+
function escapeHtml(text) {
|
|
624
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
625
|
+
}
|
|
626
|
+
/** +1/−1 for bracket chars, 0 otherwise. */
|
|
627
|
+
function bracketDelta(c) {
|
|
628
|
+
if (c === "{" || c === "(" || c === "[") return 1;
|
|
629
|
+
if (c === "}" || c === ")" || c === "]") return -1;
|
|
630
|
+
return 0;
|
|
631
|
+
}
|
|
632
|
+
/** Chars that end an import statement at depth ≤0: `;` (consumed) or a
|
|
633
|
+
* newline after the module-specifier string (left in place). */
|
|
634
|
+
function stmtTerminator(c, depth, specSeen) {
|
|
635
|
+
if (c === ";" && depth <= 0) return "past";
|
|
636
|
+
if (c === "\n" && depth <= 0 && specSeen) return "at";
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
/** End index of the statement starting at `at`: first top-level `;`, or
|
|
640
|
+
* the first newline after the module-specifier string (covers multiline
|
|
641
|
+
* `import {…}\n from "x"` and `import "x"` without a semicolon). */
|
|
642
|
+
function scanStatementEnd(code, at) {
|
|
643
|
+
let i = at;
|
|
644
|
+
let depth = 0;
|
|
645
|
+
let specSeen = false;
|
|
646
|
+
while (i < code.length) {
|
|
647
|
+
const opaque = scanOpaqueEnd(code, i);
|
|
648
|
+
if (opaque.end > i + 1) {
|
|
649
|
+
specSeen = specSeen || opaque.isString;
|
|
650
|
+
i = opaque.end;
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
const term = stmtTerminator(code[i], depth, specSeen);
|
|
654
|
+
if (term === "past") return i + 1;
|
|
655
|
+
if (term === "at") return i;
|
|
656
|
+
depth += bracketDelta(code[i]);
|
|
657
|
+
i++;
|
|
658
|
+
}
|
|
659
|
+
return i;
|
|
660
|
+
}
|
|
661
|
+
/** Same-line statement tail: horizontal whitespace then an optional `;`.
|
|
662
|
+
* Never eats the newline — the following line is the next statement. */
|
|
663
|
+
function scanStmtTail(code, at) {
|
|
664
|
+
let i = at;
|
|
665
|
+
while (i < code.length && (code[i] === " " || code[i] === " ")) i++;
|
|
666
|
+
if (code[i] === ";") i++;
|
|
667
|
+
return i;
|
|
668
|
+
}
|
|
669
|
+
/** End index of the `from "…"` clause starting after `from`, or `at` when
|
|
670
|
+
* the clause is malformed — let the evaluator complain. */
|
|
671
|
+
function scanFromClause(code, next) {
|
|
672
|
+
const specAt = skipTrivia(code, next + 4);
|
|
673
|
+
const q = code[specAt];
|
|
674
|
+
if (q === "\"" || q === "'") return scanStmtTail(code, scanString(code, specAt));
|
|
675
|
+
return next;
|
|
676
|
+
}
|
|
677
|
+
/** End index of `export * [as ns] [from "…"]` — the `*` is at `at`. */
|
|
678
|
+
function scanExportStar(code, at) {
|
|
679
|
+
let i = skipTrivia(code, at + 1);
|
|
680
|
+
if (keywordAt(code, i, "as")) {
|
|
681
|
+
i = skipTrivia(code, i + 2);
|
|
682
|
+
i = skipIdent(code, i);
|
|
683
|
+
}
|
|
684
|
+
const next = skipTrivia(code, i);
|
|
685
|
+
if (keywordAt(code, next, "from")) return scanFromClause(code, next);
|
|
686
|
+
return scanStmtTail(code, i);
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* End index of `export {…}` / `export * [as ns] [from "…"]` starting at
|
|
690
|
+
* the `{` or `*`. A bare list ends at its `;` or newline — unlike an
|
|
691
|
+
* import, a newline after the closing brace must end the statement or
|
|
692
|
+
* the next line would be eaten with it.
|
|
693
|
+
*/
|
|
694
|
+
function scanExportListEnd(code, at) {
|
|
695
|
+
if (code[at] === "*") return scanExportStar(code, at);
|
|
696
|
+
const close = matchBrace(code, at);
|
|
697
|
+
const i = close < 0 ? code.length : close + 1;
|
|
698
|
+
const next = skipTrivia(code, i);
|
|
699
|
+
if (keywordAt(code, next, "from")) return scanFromClause(code, next);
|
|
700
|
+
return scanStmtTail(code, i);
|
|
701
|
+
}
|
|
702
|
+
function atStmtLevel(st) {
|
|
703
|
+
return st.stack.length === 0 || st.stack.at(-1) === "block";
|
|
704
|
+
}
|
|
705
|
+
/** Copy a string literal or comment verbatim into `out`. */
|
|
706
|
+
function copyOpaque(code, st, out) {
|
|
707
|
+
const opaque = scanOpaqueEnd(code, st.i);
|
|
708
|
+
if (opaque.end <= st.i + 1) return false;
|
|
709
|
+
out.push(code.slice(st.i, opaque.end));
|
|
710
|
+
if (opaque.isString) {
|
|
711
|
+
st.stmtStart = false;
|
|
712
|
+
st.operandEnd = true;
|
|
713
|
+
}
|
|
714
|
+
st.i = opaque.end;
|
|
715
|
+
return true;
|
|
716
|
+
}
|
|
717
|
+
/** Handle `{}`/`()`/`[]` — pushes onto the block/expr stack and sets the
|
|
718
|
+
* operand context: `}` closing an object literal ends a value, `}`
|
|
719
|
+
* closing a block ends a statement. */
|
|
720
|
+
function applyBracket(code, st, out) {
|
|
721
|
+
const c = code[st.i];
|
|
722
|
+
if (c === "{") {
|
|
723
|
+
st.stack.push(st.stmtStart ? "block" : "expr");
|
|
724
|
+
st.operandEnd = false;
|
|
725
|
+
} else if (c === "}") {
|
|
726
|
+
const popped = st.stack.pop();
|
|
727
|
+
st.stmtStart = popped === "block";
|
|
728
|
+
st.operandEnd = popped === "expr";
|
|
729
|
+
} else if (c === "(" || c === "[") {
|
|
730
|
+
st.stack.push("expr");
|
|
731
|
+
st.stmtStart = false;
|
|
732
|
+
st.operandEnd = false;
|
|
733
|
+
} else if (c === ")" || c === "]") {
|
|
734
|
+
st.stack.pop();
|
|
735
|
+
st.stmtStart = false;
|
|
736
|
+
st.operandEnd = true;
|
|
737
|
+
} else return false;
|
|
738
|
+
out.push(c ?? "");
|
|
739
|
+
st.i++;
|
|
740
|
+
return true;
|
|
741
|
+
}
|
|
742
|
+
/** Handle `\n`, `;`, and whitespace — statement boundaries at statement
|
|
743
|
+
* level only (a `;` inside `for (;;)` doesn't count). A newline never
|
|
744
|
+
* resets operand context: `foo\n/bar/` divides, per ASI rules. */
|
|
745
|
+
function applyTerminator(code, st, out) {
|
|
746
|
+
const c = code[st.i];
|
|
747
|
+
if (c !== ";" && c !== "\n" && !isWsChar(c)) return false;
|
|
748
|
+
if (c === ";") {
|
|
749
|
+
if (atStmtLevel(st)) st.stmtStart = true;
|
|
750
|
+
st.operandEnd = false;
|
|
751
|
+
} else if (c === "\n" && atStmtLevel(st)) st.stmtStart = true;
|
|
752
|
+
out.push(c ?? "");
|
|
753
|
+
st.i++;
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
/** Handle `/` — a regex literal when an operand is expected, a division
|
|
757
|
+
* operator when one just ended. Comments never reach here (copyOpaque
|
|
758
|
+
* already consumed them). */
|
|
759
|
+
function applySlash(code, st, out) {
|
|
760
|
+
if (code[st.i] !== "/" || commentAt(code, st.i)) return false;
|
|
761
|
+
if (st.operandEnd) {
|
|
762
|
+
out.push("/");
|
|
763
|
+
st.i++;
|
|
764
|
+
st.operandEnd = false;
|
|
765
|
+
} else {
|
|
766
|
+
const end = scanRegex(code, st.i);
|
|
767
|
+
out.push(code.slice(st.i, end));
|
|
768
|
+
st.i = end;
|
|
769
|
+
st.operandEnd = true;
|
|
770
|
+
}
|
|
771
|
+
st.stmtStart = false;
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
/** Handle an `import` keyword at statement start. Dynamic `import (…)`
|
|
775
|
+
* and `import.meta` pass through verbatim — whitespace and comments may
|
|
776
|
+
* sit between the keyword and the paren. */
|
|
777
|
+
function tryImport(code, st, out) {
|
|
778
|
+
if (!st.stmtStart || !keywordAt(code, st.i, "import")) return false;
|
|
779
|
+
const nc = code[skipTrivia(code, st.i + 6)];
|
|
780
|
+
if (nc === "(" || nc === ".") {
|
|
781
|
+
out.push("import");
|
|
782
|
+
st.i += 6;
|
|
783
|
+
st.stmtStart = false;
|
|
784
|
+
st.operandEnd = true;
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
st.i = scanStatementEnd(code, st.i + 6);
|
|
788
|
+
st.stmtStart = false;
|
|
789
|
+
st.operandEnd = false;
|
|
790
|
+
return true;
|
|
791
|
+
}
|
|
792
|
+
/** Handle `export default` (→ `return`), `export {…}`/`export *` (removed)
|
|
793
|
+
* and `export <decl>` (keyword dropped — the eval context is a function
|
|
794
|
+
* body, where `export const` would be a syntax error). */
|
|
795
|
+
function tryExport(code, st, out) {
|
|
796
|
+
if (!st.stmtStart || !keywordAt(code, st.i, "export")) return false;
|
|
797
|
+
const j = skipTrivia(code, st.i + 6);
|
|
798
|
+
if (!st.exportDefaultDone && code.startsWith("default", j) && !isIdentChar(code[j + 7])) {
|
|
799
|
+
out.push("return ");
|
|
800
|
+
st.exportDefaultDone = true;
|
|
801
|
+
st.i = skipTrivia(code, j + 7);
|
|
802
|
+
st.stmtStart = false;
|
|
803
|
+
st.operandEnd = false;
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
if (code[j] === "{" || code[j] === "*") {
|
|
807
|
+
st.i = scanExportListEnd(code, j);
|
|
808
|
+
st.stmtStart = false;
|
|
809
|
+
st.operandEnd = false;
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
st.i = j;
|
|
813
|
+
st.stmtStart = false;
|
|
814
|
+
st.operandEnd = false;
|
|
815
|
+
return true;
|
|
816
|
+
}
|
|
817
|
+
/** Emit an identifier run — keyword table decides whether `/` after it
|
|
818
|
+
* divides (`foo /x/`) or opens a regex (`return /x/`). */
|
|
819
|
+
function tryWord(code, st, out) {
|
|
820
|
+
if (!isIdentChar(code[st.i])) return false;
|
|
821
|
+
const wend = skipIdent(code, st.i);
|
|
822
|
+
out.push(code.slice(st.i, wend));
|
|
823
|
+
st.operandEnd = operandAfterWord(code.slice(st.i, wend));
|
|
824
|
+
st.stmtStart = false;
|
|
825
|
+
st.i = wend;
|
|
826
|
+
return true;
|
|
827
|
+
}
|
|
828
|
+
/** Char handlers tried in order — first true wins. Any char left over is
|
|
829
|
+
* copied verbatim as a non-operand punctuation token. */
|
|
830
|
+
const HANDLERS = [
|
|
831
|
+
copyOpaque,
|
|
832
|
+
applyBracket,
|
|
833
|
+
applyTerminator,
|
|
834
|
+
applySlash,
|
|
835
|
+
tryImport,
|
|
836
|
+
tryExport,
|
|
837
|
+
tryWord
|
|
838
|
+
];
|
|
839
|
+
function advanceScan(code, st, out) {
|
|
840
|
+
for (const handle of HANDLERS) if (handle(code, st, out)) return;
|
|
841
|
+
st.stmtStart = false;
|
|
842
|
+
st.operandEnd = false;
|
|
843
|
+
out.push(code[st.i] ?? "");
|
|
844
|
+
st.i++;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Strip import/export statements from user code for eval — a linear
|
|
848
|
+
* single-pass scan, so multiline imports and comments/strings are handled
|
|
849
|
+
* and pathological spacing cannot cause quadratic backtracking.
|
|
850
|
+
*
|
|
851
|
+
* `import …` statements are removed whole. `export default` becomes
|
|
852
|
+
* `return`, `export {…}`/`export *` are removed, and `export` before a
|
|
853
|
+
* declaration is dropped (the eval context is a function body, where
|
|
854
|
+
* `export const` would be a syntax error).
|
|
855
|
+
*
|
|
856
|
+
* Note: TypeScript-specific syntax (type annotations, interfaces, enums)
|
|
857
|
+
* is not stripped — the playground uses Monaco's TypeScript language mode
|
|
858
|
+
* for editing, but evaluation is plain JavaScript. Users should write
|
|
859
|
+
* JS-compatible code or use the `as any` escape hatch sparingly.
|
|
860
|
+
*/
|
|
861
|
+
function preprocessCode(code) {
|
|
862
|
+
const out = [];
|
|
863
|
+
const st = {
|
|
864
|
+
i: 0,
|
|
865
|
+
stmtStart: true,
|
|
866
|
+
exportDefaultDone: false,
|
|
867
|
+
stack: [],
|
|
868
|
+
operandEnd: false
|
|
869
|
+
};
|
|
870
|
+
while (st.i < code.length) {
|
|
871
|
+
if (code[st.i] === void 0) break;
|
|
872
|
+
advanceScan(code, st, out);
|
|
873
|
+
}
|
|
874
|
+
return out.join("");
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Evaluate user code and return the Project.
|
|
878
|
+
*
|
|
879
|
+
* SECURITY: This uses `new Function()` to execute user-provided code.
|
|
880
|
+
* This is intentional — the playground is a local development tool where
|
|
881
|
+
* the user writes and runs their own pipeline code. The code runs in the
|
|
882
|
+
* browser's main page context (not a sandboxed iframe or worker) because
|
|
883
|
+
* the playground needs to support synchronous FunctionStep execution and
|
|
884
|
+
* direct access to the pipeline constructs. This is the same trust model
|
|
885
|
+
* as a local REPL or `node -e`.
|
|
886
|
+
*/
|
|
887
|
+
function evaluateUserCode(code) {
|
|
888
|
+
const params = [
|
|
889
|
+
"Project",
|
|
890
|
+
"Pipeline",
|
|
891
|
+
"FunctionStep",
|
|
892
|
+
"Entry",
|
|
893
|
+
preprocessCode(code)
|
|
894
|
+
];
|
|
895
|
+
const result = new Function(...params)(Project, Pipeline, FunctionStep, Entry);
|
|
896
|
+
if (!(result instanceof Project)) throw new TypeError("Code must export a Project instance");
|
|
897
|
+
return result;
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Run a pipeline with a timeout. If a step returns a never-resolving
|
|
901
|
+
* promise, the timeout ensures the UI recovers instead of staying
|
|
902
|
+
* stuck in "Running" state forever.
|
|
903
|
+
*/
|
|
904
|
+
async function runPipelineWithTimeout(project, timeoutMs) {
|
|
905
|
+
let timer;
|
|
906
|
+
try {
|
|
907
|
+
return await Promise.race([runPipeline(project), new Promise((_, reject) => {
|
|
908
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`Pipeline timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
909
|
+
})]);
|
|
910
|
+
} finally {
|
|
911
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
//#endregion
|
|
915
|
+
//#region src/embed.ts
|
|
916
|
+
const STYLE = `
|
|
917
|
+
font-family: monospace; font-size: 12px;
|
|
918
|
+
background: #0d1117; color: #c9d1d9;
|
|
919
|
+
border: 1px solid #30363d; border-radius: 6px;
|
|
920
|
+
display: flex; flex-direction: column; overflow: hidden;
|
|
921
|
+
`;
|
|
922
|
+
function createHeader(doc) {
|
|
923
|
+
const header = doc.createElement("div");
|
|
924
|
+
header.setAttribute("style", "display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.6rem;background:#161b22;border-bottom:1px solid #30363d;");
|
|
925
|
+
const runBtn = doc.createElement("button");
|
|
926
|
+
runBtn.textContent = "Run";
|
|
927
|
+
runBtn.setAttribute("style", "padding:0.2rem 0.8rem;border:1px solid #30363d;border-radius:6px;background:#1f6feb;color:#fff;cursor:pointer;font-size:0.8rem;");
|
|
928
|
+
const status = doc.createElement("span");
|
|
929
|
+
status.setAttribute("style", "font-size:0.75rem;color:#8b949e;");
|
|
930
|
+
status.textContent = "Ready";
|
|
931
|
+
header.append(runBtn, status);
|
|
932
|
+
return {
|
|
933
|
+
header,
|
|
934
|
+
runBtn,
|
|
935
|
+
status
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
function createCodeEl(doc, code, readonly) {
|
|
939
|
+
if (readonly) {
|
|
940
|
+
const pre = doc.createElement("pre");
|
|
941
|
+
pre.setAttribute("style", "margin:0;padding:0.75rem;overflow:auto;max-height:16rem;white-space:pre;");
|
|
942
|
+
pre.textContent = code;
|
|
943
|
+
return pre;
|
|
944
|
+
}
|
|
945
|
+
const textarea = doc.createElement("textarea");
|
|
946
|
+
textarea.value = code;
|
|
947
|
+
textarea.setAttribute("style", "flex:0 0 12rem;width:100%;background:#0d1117;color:#c9d1d9;border:none;border-bottom:1px solid #30363d;padding:0.75rem;resize:vertical;outline:none;font:inherit;");
|
|
948
|
+
return textarea;
|
|
949
|
+
}
|
|
950
|
+
function createFrame(doc) {
|
|
951
|
+
const frame = doc.createElement("iframe");
|
|
952
|
+
frame.setAttribute("sandbox", "allow-scripts");
|
|
953
|
+
frame.setAttribute("style", "flex:1;min-height:10rem;width:100%;border:none;background:#0d1117;");
|
|
954
|
+
return frame;
|
|
955
|
+
}
|
|
956
|
+
const BODY_DARK = "<body style=\"background:#0d1117;font-family:monospace;padding:1rem;";
|
|
957
|
+
/** Failure banner HTML — string concat keeps user data flowing through
|
|
958
|
+
* escapeHtml explicitly, never through template interpolation. */
|
|
959
|
+
function failureBanner(result) {
|
|
960
|
+
return [
|
|
961
|
+
"<div style=\"background:#3d1d1f;color:#f85149;padding:0.6rem 1rem;font-family:monospace;border-bottom:1px solid #30363d;\">",
|
|
962
|
+
"<strong>Run failed</strong>",
|
|
963
|
+
"<pre style=\"margin:0.4rem 0 0;white-space:pre-wrap;\">",
|
|
964
|
+
escapeHtml(result.steps.filter((s) => s.status === "failure").map((s) => `${s.stepId}: ${s.error ?? "failed"}`).join("\n") || `${result.steps.length} steps failed`),
|
|
965
|
+
"</pre></div>"
|
|
966
|
+
].join("");
|
|
967
|
+
}
|
|
968
|
+
/** Document shown when the run failed — partial findings still render
|
|
969
|
+
* underneath the banner so finished steps aren't hidden by the error. */
|
|
970
|
+
function failureDoc(result, banner) {
|
|
971
|
+
if (result.findings.length === 0) return "<!DOCTYPE html><html>" + BODY_DARK + "color:#f85149;\">" + banner + "</body></html>";
|
|
972
|
+
const sarif = generateSarifHtml(result.findings);
|
|
973
|
+
return sarif.includes("<body>") ? sarif.replace("<body>", "<body>" + banner) : banner + sarif;
|
|
974
|
+
}
|
|
975
|
+
/** Document shown for a clean run with zero findings. */
|
|
976
|
+
function successDoc(result) {
|
|
977
|
+
return [
|
|
978
|
+
"<!DOCTYPE html><html>",
|
|
979
|
+
BODY_DARK,
|
|
980
|
+
"color:#3fb950;\">",
|
|
981
|
+
"<h3>No findings — all checks passed</h3><p>",
|
|
982
|
+
escapeHtml(String(result.steps.length)),
|
|
983
|
+
" steps in ",
|
|
984
|
+
escapeHtml(String(result.totalDurationMs)),
|
|
985
|
+
"ms</p></body></html>"
|
|
986
|
+
].join("");
|
|
987
|
+
}
|
|
988
|
+
/** Document shown when the runner itself threw (eval or pipeline error). */
|
|
989
|
+
function errorDoc(e) {
|
|
990
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
991
|
+
return [
|
|
992
|
+
"<!DOCTYPE html><html>",
|
|
993
|
+
BODY_DARK,
|
|
994
|
+
"color:#f85149;\">",
|
|
995
|
+
"<h3>Error</h3><pre>",
|
|
996
|
+
escapeHtml(msg),
|
|
997
|
+
"</pre></body></html>"
|
|
998
|
+
].join("");
|
|
999
|
+
}
|
|
1000
|
+
function statusLine(result) {
|
|
1001
|
+
if (result.success) return `${result.findings.length} findings · ${result.totalDurationMs}ms`;
|
|
1002
|
+
return `${result.steps.filter((s) => s.status === "failure").length} step(s) failed · ${result.findings.length} findings`;
|
|
1003
|
+
}
|
|
1004
|
+
/** Render a completed run into the findings frame. */
|
|
1005
|
+
function renderResult(result, showFindings) {
|
|
1006
|
+
if (!result.success) {
|
|
1007
|
+
showFindings(failureDoc(result, failureBanner(result)));
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (result.findings.length === 0) {
|
|
1011
|
+
showFindings(successDoc(result));
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
showFindings(generateSarifHtml(result.findings));
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Mount a self-contained runner inside `el`. `dispose()` tears the mount
|
|
1018
|
+
* down. With `autoRun: true` the runner executes `code` once on mount;
|
|
1019
|
+
* otherwise it waits for the Run button — embeds never auto-execute code
|
|
1020
|
+
* the embedder didn't explicitly allow.
|
|
1021
|
+
*/
|
|
1022
|
+
function mountRunner(el, opts = {}) {
|
|
1023
|
+
const doc = el.ownerDocument;
|
|
1024
|
+
const code = opts.code ?? DEFAULT_CODE;
|
|
1025
|
+
const root = doc.createElement("div");
|
|
1026
|
+
root.setAttribute("style", STYLE);
|
|
1027
|
+
const { header, runBtn, status } = createHeader(doc);
|
|
1028
|
+
const codeEl = createCodeEl(doc, code, opts.readonly === true);
|
|
1029
|
+
const frame = createFrame(doc);
|
|
1030
|
+
root.append(header, codeEl, frame);
|
|
1031
|
+
el.appendChild(root);
|
|
1032
|
+
const getCode = () => opts.readonly === true ? code : codeEl.value;
|
|
1033
|
+
const run = async () => {
|
|
1034
|
+
runBtn.disabled = true;
|
|
1035
|
+
status.textContent = "Running...";
|
|
1036
|
+
let result;
|
|
1037
|
+
try {
|
|
1038
|
+
result = await runPipelineWithTimeout(evaluateUserCode(getCode()), 3e4);
|
|
1039
|
+
renderResult(result, (html) => {
|
|
1040
|
+
frame.srcdoc = html;
|
|
1041
|
+
});
|
|
1042
|
+
status.textContent = statusLine(result);
|
|
1043
|
+
} catch (e) {
|
|
1044
|
+
frame.srcdoc = errorDoc(e);
|
|
1045
|
+
status.textContent = "Error";
|
|
1046
|
+
runBtn.disabled = false;
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
runBtn.disabled = false;
|
|
1050
|
+
opts.onRun?.(result);
|
|
1051
|
+
};
|
|
1052
|
+
const onClick = () => {
|
|
1053
|
+
run();
|
|
1054
|
+
};
|
|
1055
|
+
runBtn.addEventListener("click", onClick);
|
|
1056
|
+
if (opts.autoRun === true) run();
|
|
1057
|
+
return { dispose() {
|
|
1058
|
+
runBtn.removeEventListener("click", onClick);
|
|
1059
|
+
root.remove();
|
|
1060
|
+
} };
|
|
1061
|
+
}
|
|
1062
|
+
//#endregion
|
|
1063
|
+
//#region src/transpile.ts
|
|
1064
|
+
function isOpenBracket(c) {
|
|
1065
|
+
return c === "{" || c === "[" || c === "(";
|
|
1066
|
+
}
|
|
1067
|
+
function isCloseBracket(c) {
|
|
1068
|
+
return c === "}" || c === "]" || c === ")";
|
|
1069
|
+
}
|
|
1070
|
+
function skipWs(code, i) {
|
|
1071
|
+
while (i < code.length && isWsChar(code[i])) i++;
|
|
1072
|
+
return i;
|
|
1073
|
+
}
|
|
1074
|
+
/** One token of an operand-tracking scan — identifiers (with `word`),
|
|
1075
|
+
* opaque spans, and regex-vs-division resolve through the shared
|
|
1076
|
+
* scanner; whitespace passes through. Null when `i` sits on
|
|
1077
|
+
* punctuation the caller's own depth/branch logic owns. */
|
|
1078
|
+
function propToken(s, i, operandEnd) {
|
|
1079
|
+
if (isWsChar(s[i])) return {
|
|
1080
|
+
end: i + 1,
|
|
1081
|
+
operandEnd
|
|
1082
|
+
};
|
|
1083
|
+
const op = scanOperand(s, i, operandEnd);
|
|
1084
|
+
if (op === null) return null;
|
|
1085
|
+
if (isIdentChar(s[i])) return {
|
|
1086
|
+
...op,
|
|
1087
|
+
word: s.slice(i, op.end)
|
|
1088
|
+
};
|
|
1089
|
+
return op;
|
|
1090
|
+
}
|
|
1091
|
+
/** Apply a bracket/other punctuation char to depth + operand state. */
|
|
1092
|
+
function applyPunctuation(c, st) {
|
|
1093
|
+
if (isOpenBracket(c)) {
|
|
1094
|
+
st.depth++;
|
|
1095
|
+
st.operandEnd = false;
|
|
1096
|
+
} else if (isCloseBracket(c)) {
|
|
1097
|
+
st.depth--;
|
|
1098
|
+
st.operandEnd = true;
|
|
1099
|
+
} else st.operandEnd = false;
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Index of the `:` following a depth-0 `key` in a props object body, or -1.
|
|
1103
|
+
* A `dependencies` declared inside `fn` never counts — only top-level keys.
|
|
1104
|
+
*/
|
|
1105
|
+
function findPropKey(props, key) {
|
|
1106
|
+
const st = {
|
|
1107
|
+
depth: 0,
|
|
1108
|
+
operandEnd: false
|
|
1109
|
+
};
|
|
1110
|
+
let i = 0;
|
|
1111
|
+
while (i < props.length) {
|
|
1112
|
+
const tok = propToken(props, i, st.operandEnd);
|
|
1113
|
+
if (tok === null) {
|
|
1114
|
+
applyPunctuation(props[i], st);
|
|
1115
|
+
i++;
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
if (st.depth === 0 && tok.word === key) {
|
|
1119
|
+
const hit = skipWs(props, tok.end);
|
|
1120
|
+
if (props[hit] === ":") return hit;
|
|
1121
|
+
}
|
|
1122
|
+
st.operandEnd = tok.operandEnd;
|
|
1123
|
+
i = tok.end;
|
|
1124
|
+
}
|
|
1125
|
+
return -1;
|
|
1126
|
+
}
|
|
1127
|
+
/** True when the comment at `k` is a top-level tail comment — `deps: [] //
|
|
1128
|
+
* note` — and therefore not part of the value. Emitting it would comment
|
|
1129
|
+
* out the generated `}` after `dependsOn: <value>`. */
|
|
1130
|
+
function isTailComment(props, k, after) {
|
|
1131
|
+
const ahead = skipWs(props, after);
|
|
1132
|
+
return ahead >= props.length || props[ahead] === ",";
|
|
1133
|
+
}
|
|
1134
|
+
/** End index of a prop value starting at `j` — the next top-level comma
|
|
1135
|
+
* or the end of the props body, with strings/comments/brackets balanced. */
|
|
1136
|
+
function propValueEnd(props, j) {
|
|
1137
|
+
const st = {
|
|
1138
|
+
depth: 0,
|
|
1139
|
+
operandEnd: false
|
|
1140
|
+
};
|
|
1141
|
+
let k = j;
|
|
1142
|
+
while (k < props.length) {
|
|
1143
|
+
if (isOpaqueStart(props, k)) {
|
|
1144
|
+
const after = scanOpaqueEnd(props, k).end;
|
|
1145
|
+
if (st.depth === 0 && isTailComment(props, k, after)) return k;
|
|
1146
|
+
k = after;
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
const tok = propToken(props, k, st.operandEnd);
|
|
1150
|
+
if (tok !== null) {
|
|
1151
|
+
st.operandEnd = tok.operandEnd;
|
|
1152
|
+
k = tok.end;
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
if (props[k] === "," && st.depth === 0) return k;
|
|
1156
|
+
applyPunctuation(props[k], st);
|
|
1157
|
+
k++;
|
|
1158
|
+
}
|
|
1159
|
+
return k;
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Extract a top-level `key: <value>` span from a props object body.
|
|
1163
|
+
* Only depth-0 keys match — a `dependencies` declared inside `fn` never
|
|
1164
|
+
* counts. The value runs to the next top-level `,`, with strings,
|
|
1165
|
+
* comments, and nested brackets all balanced.
|
|
1166
|
+
*/
|
|
1167
|
+
function extractProp(props, key) {
|
|
1168
|
+
const colon = findPropKey(props, key);
|
|
1169
|
+
if (colon < 0) return void 0;
|
|
1170
|
+
const j = skipWs(props, colon + 1);
|
|
1171
|
+
return props.slice(j, propValueEnd(props, j)).trim();
|
|
1172
|
+
}
|
|
1173
|
+
/** Consume trailing spaces/tabs plus one optional `;` — never the newline.
|
|
1174
|
+
* Eating it would fuse the emitted import with the next line
|
|
1175
|
+
* (`from "@sverka/workflow"const x = …`). */
|
|
1176
|
+
function stmtEndPos(source, i) {
|
|
1177
|
+
let end = i;
|
|
1178
|
+
while (source[end] === " " || source[end] === " ") end++;
|
|
1179
|
+
if (source[end] === ";") end++;
|
|
1180
|
+
return end;
|
|
1181
|
+
}
|
|
1182
|
+
/** Parse one import specifier: `Name` or `Name as alias`. */
|
|
1183
|
+
function parseImportSpec(spec) {
|
|
1184
|
+
const a = skipWs(spec, 0);
|
|
1185
|
+
const wend = skipIdent(spec, a);
|
|
1186
|
+
const orig = spec.slice(a, wend);
|
|
1187
|
+
const asPos = skipWs(spec, wend);
|
|
1188
|
+
if (!spec.startsWith("as", asPos) || isIdentChar(spec[asPos + 2])) return {
|
|
1189
|
+
orig,
|
|
1190
|
+
local: orig
|
|
1191
|
+
};
|
|
1192
|
+
const b = skipWs(spec, asPos + 2);
|
|
1193
|
+
const local = spec.slice(b, skipIdent(spec, b));
|
|
1194
|
+
return local === "" ? {
|
|
1195
|
+
orig,
|
|
1196
|
+
local: orig
|
|
1197
|
+
} : {
|
|
1198
|
+
orig,
|
|
1199
|
+
local
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
/** Map `{…}` import specifiers; FunctionStep becomes ShellStep (aliases
|
|
1203
|
+
* survive: `FunctionStep as Fn` → `ShellStep as Fn`). */
|
|
1204
|
+
function mapImportSpecifiers(inner) {
|
|
1205
|
+
const mapped = [];
|
|
1206
|
+
const fnStepLocals = [];
|
|
1207
|
+
for (const spec of inner.split(",")) {
|
|
1208
|
+
const trimmed = spec.trim();
|
|
1209
|
+
if (trimmed === "") continue;
|
|
1210
|
+
const { orig, local } = parseImportSpec(trimmed);
|
|
1211
|
+
if (orig === "FunctionStep") {
|
|
1212
|
+
fnStepLocals.push(local);
|
|
1213
|
+
mapped.push(orig === local ? "ShellStep" : `ShellStep as ${local}`);
|
|
1214
|
+
} else mapped.push(trimmed);
|
|
1215
|
+
}
|
|
1216
|
+
return {
|
|
1217
|
+
mapped: mapped.join(", "),
|
|
1218
|
+
fnStepLocals
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
/** Parse `import {…} from "@sverka/playground"` at `i`, or null when the
|
|
1222
|
+
* statement has another shape/module. Caller guarantees "import" at `i`. */
|
|
1223
|
+
function parsePlaygroundImport(source, i) {
|
|
1224
|
+
const j = skipWs(source, i + 6);
|
|
1225
|
+
if (source[j] !== "{") return null;
|
|
1226
|
+
const namesEnd = matchBrace(source, j);
|
|
1227
|
+
if (namesEnd < 0) return null;
|
|
1228
|
+
const fromKw = skipWs(source, namesEnd + 1);
|
|
1229
|
+
if (!keywordAt(source, fromKw, "from")) return null;
|
|
1230
|
+
const qPos = skipWs(source, fromKw + 4);
|
|
1231
|
+
const q = source[qPos];
|
|
1232
|
+
if (q !== "\"" && q !== "'") return null;
|
|
1233
|
+
const specEnd = scanString(source, qPos);
|
|
1234
|
+
if (source.slice(qPos + 1, specEnd - 1) !== "@sverka/playground") return null;
|
|
1235
|
+
const { mapped, fnStepLocals } = mapImportSpecifiers(source.slice(j + 1, namesEnd));
|
|
1236
|
+
return {
|
|
1237
|
+
stmtEnd: stmtEndPos(source, specEnd),
|
|
1238
|
+
mapped,
|
|
1239
|
+
fnStepLocals
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Rewrite `import {…} from "@sverka/playground"` statements linearly.
|
|
1244
|
+
*
|
|
1245
|
+
* Returns the rewritten source plus every local name bound to
|
|
1246
|
+
* FunctionStep, so constructor calls under an alias are transpiled too.
|
|
1247
|
+
*/
|
|
1248
|
+
function rewritePlaygroundImports(source) {
|
|
1249
|
+
const fnStepNames = [];
|
|
1250
|
+
let out = "";
|
|
1251
|
+
let cursor = 0;
|
|
1252
|
+
let i = 0;
|
|
1253
|
+
while (i < source.length) {
|
|
1254
|
+
const end = scanOpaqueEnd(source, i).end;
|
|
1255
|
+
if (end > i + 1) {
|
|
1256
|
+
i = end;
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
if (source.startsWith("import", i) && !isIdentChar(source[i + 6])) {
|
|
1260
|
+
const stmt = parsePlaygroundImport(source, i);
|
|
1261
|
+
if (stmt !== null) {
|
|
1262
|
+
out += source.slice(cursor, i);
|
|
1263
|
+
out += `import { ${stmt.mapped} } from "@sverka/workflow"`;
|
|
1264
|
+
if (source[stmt.stmtEnd - 1] === ";") out += ";";
|
|
1265
|
+
fnStepNames.push(...stmt.fnStepLocals);
|
|
1266
|
+
cursor = stmt.stmtEnd;
|
|
1267
|
+
i = stmt.stmtEnd;
|
|
1268
|
+
continue;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
i++;
|
|
1272
|
+
}
|
|
1273
|
+
return {
|
|
1274
|
+
code: out + source.slice(cursor),
|
|
1275
|
+
fnStepNames
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
/** Index of `(` after `new <name>` where <name> ∈ names, or null. */
|
|
1279
|
+
function stepCallParen(code, afterNew, names) {
|
|
1280
|
+
const j = skipWs(code, afterNew);
|
|
1281
|
+
for (const name of names) {
|
|
1282
|
+
const next = code[j + name.length];
|
|
1283
|
+
if (code.startsWith(name, j) && !isIdentChar(next)) {
|
|
1284
|
+
const paren = skipWs(code, j + name.length);
|
|
1285
|
+
if (code[paren] === "(") return paren;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
return null;
|
|
1289
|
+
}
|
|
1290
|
+
/** Operand context after punctuation — closers end a value, everything
|
|
1291
|
+
* else (openers, operators, separators) expects one. */
|
|
1292
|
+
function punctuationOperandEnd(c) {
|
|
1293
|
+
return c === ")" || c === "]" || c === "}";
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Locate the next `new <name>(` call where <name> ∈ names. Whole-word
|
|
1297
|
+
* scanning makes boundary checks free — `myNew` or `newer` never match.
|
|
1298
|
+
* Opaque regions and regex literals are skipped inline, so a
|
|
1299
|
+
* `new FunctionStep(` inside a comment, string, or `/new Fn(/` pattern
|
|
1300
|
+
* is documentation, not a call.
|
|
1301
|
+
*/
|
|
1302
|
+
function findStepCall(code, names, from) {
|
|
1303
|
+
let operandEnd = false;
|
|
1304
|
+
let i = from;
|
|
1305
|
+
while (i < code.length) {
|
|
1306
|
+
const tok = propToken(code, i, operandEnd);
|
|
1307
|
+
if (tok !== null) {
|
|
1308
|
+
if (tok.word === "new") {
|
|
1309
|
+
const paren = stepCallParen(code, tok.end, names);
|
|
1310
|
+
if (paren !== null) return {
|
|
1311
|
+
start: i,
|
|
1312
|
+
paren
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
operandEnd = tok.operandEnd;
|
|
1316
|
+
i = tok.end;
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
operandEnd = punctuationOperandEnd(code[i]);
|
|
1320
|
+
i++;
|
|
1321
|
+
}
|
|
1322
|
+
return null;
|
|
1323
|
+
}
|
|
1324
|
+
/** End of the scope expression — the first top-level comma after `from`,
|
|
1325
|
+
* or -1 when the argument list closes/ends first. */
|
|
1326
|
+
function scanScopeEnd(code, from) {
|
|
1327
|
+
const st = {
|
|
1328
|
+
depth: 0,
|
|
1329
|
+
operandEnd: false
|
|
1330
|
+
};
|
|
1331
|
+
let k = from;
|
|
1332
|
+
while (k < code.length) {
|
|
1333
|
+
const tok = propToken(code, k, st.operandEnd);
|
|
1334
|
+
if (tok !== null) {
|
|
1335
|
+
st.operandEnd = tok.operandEnd;
|
|
1336
|
+
k = tok.end;
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
const c = code[k];
|
|
1340
|
+
if (c === "," && st.depth === 0) return k;
|
|
1341
|
+
if (isCloseBracket(c) && st.depth === 0) return -1;
|
|
1342
|
+
applyPunctuation(c, st);
|
|
1343
|
+
k++;
|
|
1344
|
+
}
|
|
1345
|
+
return -1;
|
|
1346
|
+
}
|
|
1347
|
+
/** Parse `(scope, "id", {` after the `(` at `paren`; null when the call
|
|
1348
|
+
* shape differs. A template id throws immediately — never half-converts. */
|
|
1349
|
+
function parseStepArgs(code, paren) {
|
|
1350
|
+
const scopeStart = skipWs(code, paren + 1);
|
|
1351
|
+
const comma = scanScopeEnd(code, scopeStart);
|
|
1352
|
+
if (comma < 0) return null;
|
|
1353
|
+
const qPos = skipWs(code, comma + 1);
|
|
1354
|
+
const q = code[qPos];
|
|
1355
|
+
if (q === "`") throw new PlaygroundError("TRANSPILE_FAILED", "cannot parse FunctionStep — template-literal step id");
|
|
1356
|
+
if (q !== "\"" && q !== "'") return null;
|
|
1357
|
+
const idEnd = scanString(code, qPos);
|
|
1358
|
+
const afterId = skipWs(code, idEnd);
|
|
1359
|
+
if (code[afterId] !== ",") return null;
|
|
1360
|
+
const propsOpen = skipWs(code, afterId + 1);
|
|
1361
|
+
if (code[propsOpen] !== "{") return null;
|
|
1362
|
+
return {
|
|
1363
|
+
scope: code.slice(scopeStart, comma).trim(),
|
|
1364
|
+
id: code.slice(qPos + 1, idEnd - 1),
|
|
1365
|
+
propsOpen
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
/** Emit the ShellStep placeholder replacing one FunctionStep call. */
|
|
1369
|
+
function shellStepCall(args, dependencies) {
|
|
1370
|
+
return `new ShellStep(${args.scope}, "${args.id}", { command: "echo 'TODO: port '${args.id}' — replace with the real shell command'"` + (dependencies !== void 0 ? ", dependsOn: " + dependencies : "") + " }";
|
|
1371
|
+
}
|
|
1372
|
+
/** Rewrite every `new Fn(scope, "id", {…})` call under the given local
|
|
1373
|
+
* names into ShellStep placeholders; `dependencies` → `dependsOn`. */
|
|
1374
|
+
function rewriteStepCalls(code, names) {
|
|
1375
|
+
let result = "";
|
|
1376
|
+
let cursor = 0;
|
|
1377
|
+
for (;;) {
|
|
1378
|
+
const call = findStepCall(code, names, cursor);
|
|
1379
|
+
if (call === null) break;
|
|
1380
|
+
const args = parseStepArgs(code, call.paren);
|
|
1381
|
+
if (args === null) {
|
|
1382
|
+
result += code.slice(cursor, call.start + 3);
|
|
1383
|
+
cursor = call.start + 3;
|
|
1384
|
+
continue;
|
|
1385
|
+
}
|
|
1386
|
+
const propsClose = matchBrace(code, args.propsOpen);
|
|
1387
|
+
if (propsClose < 0) throw new PlaygroundError("TRANSPILE_FAILED", `cannot parse FunctionStep "${args.id}" — unbalanced props object`);
|
|
1388
|
+
const dependencies = extractProp(code.slice(args.propsOpen + 1, propsClose), "dependencies");
|
|
1389
|
+
result += code.slice(cursor, call.start) + shellStepCall(args, dependencies);
|
|
1390
|
+
cursor = propsClose + 1;
|
|
1391
|
+
}
|
|
1392
|
+
return result + code.slice(cursor);
|
|
1393
|
+
}
|
|
1394
|
+
/** A surviving step call means a shape we could not convert — computed
|
|
1395
|
+
* scope/id or a malformed alias call. Fail loud rather than emit a
|
|
1396
|
+
* half-converted file. */
|
|
1397
|
+
function assertNoLeftoverSteps(code, names) {
|
|
1398
|
+
const call = findStepCall(code, names, 0);
|
|
1399
|
+
if (call === null) return;
|
|
1400
|
+
const scopeStart = skipWs(code, call.paren + 1);
|
|
1401
|
+
const scopeEnd = scanScopeEnd(code, scopeStart);
|
|
1402
|
+
throw new PlaygroundError("TRANSPILE_FAILED", `cannot parse FunctionStep with scope '${scopeEnd < 0 ? "?" : code.slice(scopeStart, scopeEnd).trim() || "?"}' — unsupported expression`);
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Transpile playground source into `sverka.config.ts`.
|
|
1406
|
+
*
|
|
1407
|
+
* Rewrites the `@sverka/playground` import to `@sverka/workflow` and each
|
|
1408
|
+
* `new FunctionStep(scope, "id", { fn, dependencies })` into a ShellStep
|
|
1409
|
+
* whose command echoes a placeholder — the function body has no shell
|
|
1410
|
+
* equivalent.
|
|
1411
|
+
* `dependencies` maps onto `dependsOn`. Aliased FunctionStep imports
|
|
1412
|
+
* (`FunctionStep as Fn`) are followed too.
|
|
1413
|
+
*
|
|
1414
|
+
* Throws `PlaygroundError(TRANSPILE_FAILED)` naming the offending step when
|
|
1415
|
+
* a FunctionStep cannot be parsed — a half-converted file is never emitted.
|
|
1416
|
+
*/
|
|
1417
|
+
function toSverkaConfig(source) {
|
|
1418
|
+
const { code, fnStepNames } = rewritePlaygroundImports(source);
|
|
1419
|
+
if (fnStepNames.length === 0) fnStepNames.push("FunctionStep");
|
|
1420
|
+
const out = rewriteStepCalls(code, fnStepNames);
|
|
1421
|
+
assertNoLeftoverSteps(out, fnStepNames);
|
|
1422
|
+
return out;
|
|
1423
|
+
}
|
|
1424
|
+
//#endregion
|
|
1425
|
+
export { DEFAULT_CODE, Entry, FunctionStep, Pipeline, PlaygroundError, Project, SHARE_PAYLOAD_WARN_BYTES, decodeShareLink, encodeShareLink, evaluateUserCode, mountRunner, runPipeline, runPipelineWithTimeout, toSverkaConfig };
|
|
273
1426
|
|
|
274
1427
|
//# sourceMappingURL=index.mjs.map
|