@styx-api/core 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/index.cjs +2559 -553
  2. package/dist/index.d.cts +41 -37
  3. package/dist/index.d.cts.map +1 -1
  4. package/dist/index.d.mts +41 -37
  5. package/dist/index.d.mts.map +1 -1
  6. package/dist/index.mjs +2558 -549
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +2 -1
  9. package/src/backend/argtype/__fixtures__/microsyntax.argtype +13 -0
  10. package/src/backend/argtype/__fixtures__/subcommands.argtype +25 -0
  11. package/src/backend/argtype/argtype.ts +45 -0
  12. package/src/backend/argtype/emit.ts +676 -0
  13. package/src/backend/argtype/index.ts +2 -0
  14. package/src/backend/boutiques/boutiques.ts +55 -47
  15. package/src/backend/field-defaults.ts +40 -0
  16. package/src/backend/find-struct-node.ts +7 -0
  17. package/src/backend/index.ts +2 -9
  18. package/src/backend/nipype/emit.ts +11 -3
  19. package/src/backend/python/arg-builder.ts +2 -27
  20. package/src/backend/python/emit.ts +18 -6
  21. package/src/backend/python/outputs-emit.ts +27 -42
  22. package/src/backend/python/python.ts +46 -22
  23. package/src/backend/python/validate-emit.ts +4 -1
  24. package/src/backend/resolve-output-tokens.ts +0 -76
  25. package/src/backend/typescript/arg-builder.ts +2 -24
  26. package/src/backend/typescript/emit.ts +7 -4
  27. package/src/backend/typescript/outputs-emit.ts +15 -39
  28. package/src/backend/typescript/typemap.ts +9 -1
  29. package/src/backend/typescript/typescript.ts +41 -18
  30. package/src/backend/typescript/validate-emit.ts +4 -1
  31. package/src/backend/union-variants.ts +41 -1
  32. package/src/frontend/argdump/parser.ts +20 -19
  33. package/src/frontend/argtype/__fixtures__/afni-3dtstat.argtype +41 -0
  34. package/src/frontend/argtype/__fixtures__/bet.argtype +101 -0
  35. package/src/frontend/argtype/__fixtures__/fsl-flirt.argtype +50 -0
  36. package/src/frontend/argtype/__fixtures__/wb-command-sub.argtype +39 -0
  37. package/src/frontend/argtype/ast.ts +114 -0
  38. package/src/frontend/argtype/doc.ts +56 -0
  39. package/src/frontend/argtype/frontmatter.ts +237 -0
  40. package/src/frontend/argtype/index.ts +1 -0
  41. package/src/frontend/argtype/lexer.ts +264 -0
  42. package/src/frontend/argtype/lower.ts +601 -0
  43. package/src/frontend/argtype/parser-frontend.ts +51 -0
  44. package/src/frontend/argtype/parser.ts +533 -0
  45. package/src/frontend/argtype/template.ts +147 -0
  46. package/src/frontend/boutiques/destruct-template.ts +24 -17
  47. package/src/frontend/boutiques/parser.ts +27 -8
  48. package/src/frontend/detect-format.ts +28 -3
  49. package/src/index.ts +23 -9
  50. package/src/ir/passes/canonicalize.ts +18 -5
  51. package/src/ir/passes/simplify.ts +17 -2
  52. package/src/solver/solver.ts +2 -2
package/dist/index.cjs CHANGED
@@ -206,10 +206,7 @@ var ArgdumpParser = class {
206
206
  if (nargs.__argparse__ === "REMAINDER") return {
207
207
  kind: "repeat",
208
208
  attrs: {
209
- node: {
210
- kind: "str",
211
- attrs: {}
212
- },
209
+ node,
213
210
  countMin: 0
214
211
  }
215
212
  };
@@ -580,25 +577,24 @@ var ArgdumpParser = class {
580
577
  this.error(`No valid subparsers for '${action.dest}'`);
581
578
  return null;
582
579
  }
583
- if (alts.length === 1) return alts[0];
584
- const alt = {
580
+ const node = alts.length === 1 ? alts[0] : {
585
581
  kind: "alternative",
586
582
  attrs: { alts }
587
583
  };
588
- if (!(action.subparsers_required === true || action.required === true)) {
584
+ const isRequired = action.subparsers_required === true || action.required === true;
585
+ const meta = this.buildNodeMeta(action);
586
+ if (!isRequired) {
589
587
  const opt = {
590
588
  kind: "optional",
591
- attrs: { node: alt }
589
+ attrs: { node }
592
590
  };
593
- const meta = this.buildNodeMeta(action);
594
591
  if (meta) opt.meta = meta;
595
592
  return opt;
596
593
  }
597
- const meta = this.buildNodeMeta(action);
598
- if (meta) alt.meta = meta;
599
- return alt;
594
+ if (node.kind === "alternative" && meta) node.meta = meta;
595
+ return node;
600
596
  }
601
- applyMutualExclusion(actionsByDest, groups, nodes, nodesByDest) {
597
+ applyMutualExclusion(groups, nodes, nodesByDest) {
602
598
  const excluded = /* @__PURE__ */ new Set();
603
599
  for (const group of groups) {
604
600
  if (!isObject$3(group)) continue;
@@ -621,136 +617,1682 @@ var ArgdumpParser = class {
621
617
  ...inner.meta,
622
618
  name: inner.meta?.name ?? findDeepName$1(inner) ?? dest
623
619
  };
624
- altMembers.push(inner);
625
- excluded.add(dest);
620
+ altMembers.push(inner);
621
+ excluded.add(dest);
622
+ }
623
+ const alt = {
624
+ kind: "alternative",
625
+ attrs: { alts: altMembers }
626
+ };
627
+ const isRequired = group.required === true;
628
+ let groupNode;
629
+ if (isRequired) groupNode = alt;
630
+ else groupNode = {
631
+ kind: "optional",
632
+ attrs: { node: alt }
633
+ };
634
+ const groupName = (isString$3(group.title) ? group.title : void 0) ?? (memberDests.length === 2 ? `${memberDests[0]}_or_${memberDests[1]}` : `${memberDests[0]}_choice`);
635
+ groupNode.meta = {
636
+ ...groupNode.meta,
637
+ name: groupName
638
+ };
639
+ const firstDest = memberDests[0];
640
+ const firstIdx = nodes.findIndex((n) => n === nodesByDest.get(firstDest));
641
+ if (firstIdx >= 0) nodes.splice(firstIdx, 0, groupNode);
642
+ else nodes.push(groupNode);
643
+ }
644
+ return nodes.filter((n) => {
645
+ for (const [dest, node] of nodesByDest) if (node === n && excluded.has(dest)) return false;
646
+ return true;
647
+ });
648
+ }
649
+ parseParserInfo(descriptor) {
650
+ const actions = descriptor.actions;
651
+ if (!isArray$3(actions)) return {
652
+ kind: "sequence",
653
+ attrs: { nodes: [] }
654
+ };
655
+ const positionals = [];
656
+ const optionals = [];
657
+ const nodesByDest = /* @__PURE__ */ new Map();
658
+ for (const rawAction of actions) {
659
+ if (!isObject$3(rawAction)) continue;
660
+ const node = this.buildAction(rawAction);
661
+ if (!node) continue;
662
+ const dest = rawAction.dest;
663
+ if (isString$3(dest)) nodesByDest.set(dest, node);
664
+ if (this.isPositional(rawAction) && rawAction.action_type !== "parsers") positionals.push(node);
665
+ else optionals.push(node);
666
+ }
667
+ let allNodes = [...positionals, ...optionals];
668
+ const mutexGroups = descriptor.mutually_exclusive_groups;
669
+ if (isArray$3(mutexGroups) && mutexGroups.length > 0) allNodes = this.applyMutualExclusion(mutexGroups, allNodes, nodesByDest);
670
+ return {
671
+ kind: "sequence",
672
+ attrs: { nodes: allNodes }
673
+ };
674
+ }
675
+ parse(source, _filename) {
676
+ this.reset();
677
+ const descriptor = this.parseJSON(source);
678
+ if (descriptor === null) return {
679
+ expr: {
680
+ kind: "sequence",
681
+ attrs: { nodes: [] }
682
+ },
683
+ errors: this.errors,
684
+ warnings: this.warnings
685
+ };
686
+ const meta = this.buildAppMeta(descriptor);
687
+ if (!meta) {
688
+ this.error("Descriptor is missing prog");
689
+ return {
690
+ expr: {
691
+ kind: "sequence",
692
+ attrs: { nodes: [] }
693
+ },
694
+ errors: this.errors,
695
+ warnings: this.warnings
696
+ };
697
+ }
698
+ const expr = this.parseParserInfo(descriptor);
699
+ if (expr === null) {
700
+ this.error("Failed to parse argument structure");
701
+ return {
702
+ expr: {
703
+ kind: "sequence",
704
+ attrs: { nodes: [] }
705
+ },
706
+ errors: this.errors,
707
+ warnings: this.warnings
708
+ };
709
+ }
710
+ const prog = descriptor.prog;
711
+ if (isString$3(prog) && prog) expr.attrs.nodes.unshift({
712
+ kind: "literal",
713
+ attrs: { str: prog }
714
+ });
715
+ if (!expr.meta?.name && meta.id) expr.meta = {
716
+ ...expr.meta,
717
+ name: meta.id
718
+ };
719
+ return {
720
+ meta,
721
+ expr,
722
+ errors: this.errors,
723
+ warnings: this.warnings
724
+ };
725
+ }
726
+ };
727
+
728
+ //#endregion
729
+ //#region src/frontend/argtype/lexer.ts
730
+ const PUNCT = {
731
+ ":": "colon",
732
+ "=": "eq",
733
+ "|": "pipe",
734
+ ".": "dot",
735
+ ",": "comma",
736
+ "(": "lparen",
737
+ ")": "rparen"
738
+ };
739
+ function isIdentStart(ch) {
740
+ return /[A-Za-z_]/.test(ch);
741
+ }
742
+ function isIdentPart(ch) {
743
+ return /[A-Za-z0-9_]/.test(ch);
744
+ }
745
+ function isDigit(ch) {
746
+ return ch >= "0" && ch <= "9";
747
+ }
748
+ function lex(source) {
749
+ const tokens = [];
750
+ const errors = [];
751
+ let i = 0;
752
+ let line = 1;
753
+ let col = 1;
754
+ const peek = (o = 0) => source[i + o] ?? "";
755
+ const advance = () => {
756
+ const ch = source[i++];
757
+ if (ch === "\n") {
758
+ line++;
759
+ col = 1;
760
+ } else col++;
761
+ return ch;
762
+ };
763
+ const push = (kind, value, l, c) => {
764
+ tokens.push({
765
+ kind,
766
+ value,
767
+ line: l,
768
+ column: c
769
+ });
770
+ };
771
+ while (i < source.length) {
772
+ const ch = peek();
773
+ const startLine = line;
774
+ const startCol = col;
775
+ if (ch === " " || ch === " " || ch === "\r" || ch === "\n") {
776
+ advance();
777
+ continue;
778
+ }
779
+ if (ch === "/" && peek(1) === "/") {
780
+ const isDoc = peek(2) === "/";
781
+ advance();
782
+ advance();
783
+ if (isDoc) advance();
784
+ let text = "";
785
+ while (i < source.length && peek() !== "\n") text += advance();
786
+ if (isDoc) push("doc", text.trim(), startLine, startCol);
787
+ continue;
788
+ }
789
+ if (ch === "\"") {
790
+ advance();
791
+ let str = "";
792
+ let closed = false;
793
+ while (i < source.length) {
794
+ const c = advance();
795
+ if (c === "\\") {
796
+ const esc = i < source.length ? advance() : "";
797
+ str += unescape(esc);
798
+ } else if (c === "\"") {
799
+ closed = true;
800
+ break;
801
+ } else if (c === "\n") break;
802
+ else str += c;
803
+ }
804
+ if (!closed) errors.push({
805
+ message: "Unterminated string literal",
806
+ line: startLine,
807
+ column: startCol
808
+ });
809
+ push("string", str, startLine, startCol);
810
+ continue;
811
+ }
812
+ if (ch === "`") {
813
+ advance();
814
+ let body = "";
815
+ let closed = false;
816
+ while (i < source.length) {
817
+ const c = advance();
818
+ if (c === "\\") {
819
+ body += c;
820
+ if (i < source.length) body += advance();
821
+ continue;
822
+ }
823
+ if (c === "`") {
824
+ closed = true;
825
+ break;
826
+ }
827
+ body += c;
828
+ }
829
+ if (!closed) errors.push({
830
+ message: "Unterminated template literal",
831
+ line: startLine,
832
+ column: startCol
833
+ });
834
+ push("template", body, startLine, startCol);
835
+ continue;
836
+ }
837
+ if (isDigit(ch) || ch === "-" && isDigit(peek(1))) {
838
+ let num = advance();
839
+ while (i < source.length && isDigit(peek())) num += advance();
840
+ if (peek() === "." && isDigit(peek(1))) {
841
+ num += advance();
842
+ while (i < source.length && isDigit(peek())) num += advance();
843
+ }
844
+ if (peek() === "e" || peek() === "E") {
845
+ const signed = peek(1) === "+" || peek(1) === "-";
846
+ if (isDigit(peek(1)) || signed && isDigit(peek(2))) {
847
+ num += advance();
848
+ if (peek() === "+" || peek() === "-") num += advance();
849
+ while (i < source.length && isDigit(peek())) num += advance();
850
+ } else {
851
+ num += advance();
852
+ errors.push({
853
+ message: `Malformed number '${num}': exponent has no digits`,
854
+ line: startLine,
855
+ column: startCol
856
+ });
857
+ }
858
+ }
859
+ if (/^\d+$/.test(num) && isIdentStart(peek())) {
860
+ let rest = "";
861
+ while (i < source.length && isIdentPart(peek())) rest += advance();
862
+ const full = num + rest;
863
+ errors.push({
864
+ message: `Identifier cannot start with a digit; quote it as "${full}"`,
865
+ line: startLine,
866
+ column: startCol
867
+ });
868
+ push("ident", full, startLine, startCol);
869
+ continue;
870
+ }
871
+ push("number", num, startLine, startCol);
872
+ continue;
873
+ }
874
+ if (isIdentStart(ch)) {
875
+ let id = advance();
876
+ while (i < source.length && isIdentPart(peek())) id += advance();
877
+ push("ident", id, startLine, startCol);
878
+ continue;
879
+ }
880
+ const punct = PUNCT[ch];
881
+ if (punct) {
882
+ advance();
883
+ push(punct, ch, startLine, startCol);
884
+ continue;
885
+ }
886
+ errors.push({
887
+ message: `Unexpected character '${ch}'`,
888
+ line: startLine,
889
+ column: startCol
890
+ });
891
+ advance();
892
+ }
893
+ push("eof", "", line, col);
894
+ return {
895
+ tokens,
896
+ errors
897
+ };
898
+ }
899
+ /** Minimal escape handling inside double-quoted strings. */
900
+ function unescape(esc) {
901
+ switch (esc) {
902
+ case "n": return "\n";
903
+ case "t": return " ";
904
+ case "r": return "\r";
905
+ case "\"": return "\"";
906
+ case "\\": return "\\";
907
+ case "": return "";
908
+ default: return esc;
909
+ }
910
+ }
911
+
912
+ //#endregion
913
+ //#region src/frontend/argtype/frontmatter.ts
914
+ function splitFrontmatter(source) {
915
+ const lines = source.split("\n");
916
+ let start = 0;
917
+ while (start < lines.length && lines[start].trim() === "") start++;
918
+ if (lines[start]?.trim() !== "---") return {
919
+ body: source,
920
+ errors: []
921
+ };
922
+ let end = -1;
923
+ for (let i = start + 1; i < lines.length; i++) if (lines[i].trim() === "---") {
924
+ end = i;
925
+ break;
926
+ }
927
+ if (end === -1) return {
928
+ body: source,
929
+ errors: ["Unterminated frontmatter block (missing closing '---')"]
930
+ };
931
+ const { value, errors } = parseYamlish(lines.slice(start + 1, end));
932
+ return {
933
+ frontmatter: value,
934
+ body: lines.map((line, i) => i >= start && i <= end ? "" : line).join("\n"),
935
+ errors
936
+ };
937
+ }
938
+ function tokenizeLines(raw) {
939
+ const out = [];
940
+ for (const line of raw) {
941
+ const noComment = stripComment(line);
942
+ if (noComment.trim() === "") continue;
943
+ const indent = noComment.length - noComment.trimStart().length;
944
+ out.push({
945
+ indent,
946
+ content: noComment.trim()
947
+ });
948
+ }
949
+ return out;
950
+ }
951
+ /** Strip a trailing `#` comment that is not inside quotes. Following YAML, a
952
+ * `#` only starts a comment at the start of the line or after whitespace, so an
953
+ * unquoted value containing `#` (e.g. a URL fragment `https://x/#frag`) is kept
954
+ * intact. */
955
+ function stripComment(line) {
956
+ let inQuote = null;
957
+ for (let i = 0; i < line.length; i++) {
958
+ const ch = line[i];
959
+ if (inQuote === "\"" && ch === "\\") {
960
+ i++;
961
+ continue;
962
+ }
963
+ if (inQuote) {
964
+ if (ch === inQuote) inQuote = null;
965
+ } else if (ch === "\"" || ch === "'") inQuote = ch;
966
+ else if (ch === "#" && (i === 0 || line[i - 1] === " " || line[i - 1] === " ")) return line.slice(0, i);
967
+ }
968
+ return line;
969
+ }
970
+ function parseYamlish(raw) {
971
+ const lines = tokenizeLines(raw);
972
+ const errors = [];
973
+ let pos = 0;
974
+ function parseBlock(minIndent) {
975
+ if (pos < lines.length && lines[pos].indent >= minIndent && lines[pos].content.startsWith("- ")) {
976
+ const seq = [];
977
+ const indent = lines[pos].indent;
978
+ while (pos < lines.length && lines[pos].indent === indent && lines[pos].content.startsWith("- ")) {
979
+ seq.push(parseScalar(lines[pos].content.slice(2).trim()));
980
+ pos++;
981
+ }
982
+ return seq;
983
+ }
984
+ const map = {};
985
+ if (pos >= lines.length) return map;
986
+ const indent = lines[pos].indent;
987
+ while (pos < lines.length && lines[pos].indent === indent && !lines[pos].content.startsWith("- ")) {
988
+ const { content } = lines[pos];
989
+ const colon = findColon(content);
990
+ if (colon === -1) {
991
+ errors.push(`Malformed frontmatter line: '${content}'`);
992
+ pos++;
993
+ continue;
994
+ }
995
+ const key = content.slice(0, colon).trim();
996
+ const valueText = content.slice(colon + 1).trim();
997
+ pos++;
998
+ if (valueText !== "") map[key] = parseScalar(valueText);
999
+ else if (pos < lines.length && lines[pos].indent > indent) map[key] = parseBlock(lines[pos].indent);
1000
+ else map[key] = null;
1001
+ }
1002
+ return map;
1003
+ }
1004
+ const value = parseBlock(0);
1005
+ return {
1006
+ value: isRecord$2(value) ? value : { _root: value },
1007
+ errors
1008
+ };
1009
+ }
1010
+ function findColon(s) {
1011
+ let inQuote = null;
1012
+ for (let i = 0; i < s.length; i++) {
1013
+ const ch = s[i];
1014
+ if (inQuote === "\"" && ch === "\\") {
1015
+ i++;
1016
+ continue;
1017
+ }
1018
+ if (inQuote) {
1019
+ if (ch === inQuote) inQuote = null;
1020
+ } else if (ch === "\"" || ch === "'") inQuote = ch;
1021
+ else if (ch === ":") return i;
1022
+ }
1023
+ return -1;
1024
+ }
1025
+ /** Reverse the backslash escapes an emitter applies to a quoted scalar
1026
+ * (`\n`, `\t`, `\r`, `\"`, `\\`), so values with newlines/quotes round-trip. */
1027
+ function unescapeScalar(s) {
1028
+ return s.replace(/\\(.)/g, (_, c) => c === "n" ? "\n" : c === "t" ? " " : c === "r" ? "\r" : c);
1029
+ }
1030
+ /** Split a flow-sequence body (`a, "b, c", [d, e]`) on top-level commas, keeping
1031
+ * commas inside quotes or a nested `[...]` intact. Empty segments (from a leading,
1032
+ * trailing, or doubled comma, or an empty `[]` body) are dropped, matching YAML;
1033
+ * a quoted empty string keeps its quotes and so survives. */
1034
+ function splitFlow(body) {
1035
+ const parts = [];
1036
+ let depth = 0;
1037
+ let inQuote = null;
1038
+ let cur = "";
1039
+ for (let i = 0; i < body.length; i++) {
1040
+ const ch = body[i];
1041
+ if (inQuote === "\"" && ch === "\\") {
1042
+ cur += ch + (body[i + 1] ?? "");
1043
+ i++;
1044
+ continue;
1045
+ }
1046
+ if (inQuote) {
1047
+ if (ch === inQuote) inQuote = null;
1048
+ cur += ch;
1049
+ } else if (ch === "\"" || ch === "'") {
1050
+ inQuote = ch;
1051
+ cur += ch;
1052
+ } else if (ch === "[") {
1053
+ depth++;
1054
+ cur += ch;
1055
+ } else if (ch === "]") {
1056
+ depth--;
1057
+ cur += ch;
1058
+ } else if (ch === "," && depth === 0) {
1059
+ parts.push(cur);
1060
+ cur = "";
1061
+ } else cur += ch;
1062
+ }
1063
+ parts.push(cur);
1064
+ return parts.map((p) => p.trim()).filter((p) => p !== "");
1065
+ }
1066
+ function parseScalar(text) {
1067
+ if (text.startsWith("[") && text.endsWith("]") && text.length >= 2) return splitFlow(text.slice(1, -1)).map(parseScalar);
1068
+ if (text.startsWith("\"") && text.endsWith("\"") && text.length >= 2) return unescapeScalar(text.slice(1, -1));
1069
+ if (text.startsWith("'") && text.endsWith("'") && text.length >= 2) return text.slice(1, -1);
1070
+ if (text === "true") return true;
1071
+ if (text === "false") return false;
1072
+ if (text === "null" || text === "~") return null;
1073
+ if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
1074
+ return text;
1075
+ }
1076
+ function isRecord$2(x) {
1077
+ return typeof x === "object" && x !== null && !Array.isArray(x);
1078
+ }
1079
+
1080
+ //#endregion
1081
+ //#region src/frontend/argtype/template.ts
1082
+ /**
1083
+ * Index of the `}` that closes an interpolation opened at `open` (the position
1084
+ * just past the `{`), or -1 if unterminated. A naive `indexOf("}")` would stop
1085
+ * at the first `}` even when it sits inside a quoted ref name (`{"a}b"}`) or a
1086
+ * quoted operation argument (`{a.or("{b}")}`); this scan skips double-quoted
1087
+ * spans (honoring `\"`) and balances nested `{}` so those round-trip.
1088
+ */
1089
+ function interpolationEnd(body, open) {
1090
+ let depth = 1;
1091
+ let inQuote = false;
1092
+ for (let j = open; j < body.length; j++) {
1093
+ const c = body[j];
1094
+ if (inQuote) {
1095
+ if (c === "\\") j++;
1096
+ else if (c === "\"") inQuote = false;
1097
+ continue;
1098
+ }
1099
+ if (c === "\"") inQuote = true;
1100
+ else if (c === "{") depth++;
1101
+ else if (c === "}" && --depth === 0) return j;
1102
+ }
1103
+ return -1;
1104
+ }
1105
+ function parseTemplate(body) {
1106
+ const tokens = [];
1107
+ const errors = [];
1108
+ let lit = "";
1109
+ let i = 0;
1110
+ const flushLit = () => {
1111
+ if (lit.length > 0) {
1112
+ tokens.push({
1113
+ kind: "literal",
1114
+ value: lit
1115
+ });
1116
+ lit = "";
1117
+ }
1118
+ };
1119
+ while (i < body.length) {
1120
+ const ch = body[i];
1121
+ if (ch === "\\" && i + 1 < body.length) {
1122
+ const next = body[i + 1];
1123
+ lit += next === "{" || next === "}" || next === "`" || next === "\\" ? next : "\\" + next;
1124
+ i += 2;
1125
+ continue;
1126
+ }
1127
+ if (ch === "{") {
1128
+ const close = interpolationEnd(body, i + 1);
1129
+ if (close === -1) {
1130
+ errors.push("Unterminated '{' in output template");
1131
+ lit += body.slice(i);
1132
+ break;
1133
+ }
1134
+ flushLit();
1135
+ const { token, error } = parseRef(body.slice(i + 1, close).trim());
1136
+ if (error) errors.push(error);
1137
+ tokens.push(token);
1138
+ i = close + 1;
1139
+ } else {
1140
+ lit += ch;
1141
+ i++;
1142
+ }
1143
+ }
1144
+ flushLit();
1145
+ return {
1146
+ tokens,
1147
+ errors
1148
+ };
1149
+ }
1150
+ /** Parse the inside of `{...}`: an optional name followed by `.op(arg)` calls.
1151
+ * The name is a bare identifier, a double-quoted string (for a non-identifier
1152
+ * target name, e.g. `{"4d_output"}`), or absent for a self-reference (`{}`). */
1153
+ function parseRef(inner) {
1154
+ const token = { kind: "ref" };
1155
+ let rest = inner;
1156
+ const quoted = /^"((?:[^"\\]|\\.)*)"/.exec(inner);
1157
+ if (quoted) {
1158
+ token.name = unescapeArg(quoted[1]);
1159
+ rest = inner.slice(quoted[0].length);
1160
+ } else {
1161
+ const nameMatch = /^[A-Za-z_][A-Za-z0-9_]*/.exec(inner);
1162
+ if (nameMatch) {
1163
+ token.name = nameMatch[0];
1164
+ rest = inner.slice(nameMatch[0].length);
1165
+ }
1166
+ }
1167
+ const opRe = /^\.\s*([A-Za-z_]+)\s*\(\s*(?:"((?:[^"\\]|\\.)*)")?\s*\)/;
1168
+ let error;
1169
+ while (rest.length > 0) {
1170
+ const m = opRe.exec(rest);
1171
+ if (!m) {
1172
+ error = `Unrecognized output-reference operation near '${rest}'`;
1173
+ break;
1174
+ }
1175
+ const op = m[1];
1176
+ const arg = m[2];
1177
+ switch (op) {
1178
+ case "strip_suffix":
1179
+ if (arg !== void 0) (token.stripSuffix ??= []).push(unescapeArg(arg));
1180
+ break;
1181
+ case "strip_prefix":
1182
+ if (arg !== void 0) (token.stripPrefix ??= []).push(unescapeArg(arg));
1183
+ break;
1184
+ case "basename":
1185
+ token.basename = true;
1186
+ break;
1187
+ case "or":
1188
+ if (arg !== void 0) token.or = unescapeArg(arg);
1189
+ break;
1190
+ default: error = `Unknown output-reference operation '${op}'`;
1191
+ }
1192
+ rest = rest.slice(m[0].length).trimStart();
1193
+ }
1194
+ return {
1195
+ token,
1196
+ ...error && { error }
1197
+ };
1198
+ }
1199
+ function unescapeArg(s) {
1200
+ return s.replace(/\\(.)/g, "$1");
1201
+ }
1202
+
1203
+ //#endregion
1204
+ //#region src/frontend/argtype/doc.ts
1205
+ /**
1206
+ * Reflow a `///` description as Markdown-style prose: a single line break is a
1207
+ * soft wrap (the lines join with a space) and a blank line starts a new
1208
+ * paragraph (kept as `\n\n`). This lets an author wrap a long description across
1209
+ * several `///` lines for readability without forcing hard breaks, while real
1210
+ * paragraph structure survives. (It does not preserve Markdown lists or indented
1211
+ * code blocks - their single line breaks are joined like ordinary prose.)
1212
+ */
1213
+ function reflowProse(text) {
1214
+ return text.split(/\n{2,}/).map((para) => para.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join(" ")).filter((para) => para.length > 0).join("\n\n");
1215
+ }
1216
+ /**
1217
+ * Split a `///` doc block into title + description. A leading Markdown H1
1218
+ * (`# Title` on the first line) is the title; everything after it is the
1219
+ * description. Without a leading `# `, the whole block is the description (no
1220
+ * title). The description is reflowed as prose (see `reflowProse`): single line
1221
+ * breaks soft-wrap, blank lines separate paragraphs.
1222
+ */
1223
+ function splitDocText(raw) {
1224
+ const newline = raw.indexOf("\n");
1225
+ const firstLine = (newline === -1 ? raw : raw.slice(0, newline)).trim();
1226
+ if (firstLine.startsWith("# ")) {
1227
+ const title = firstLine.slice(2).trim();
1228
+ const description = reflowProse(newline === -1 ? "" : raw.slice(newline + 1));
1229
+ return {
1230
+ ...title && { title },
1231
+ ...description && { description }
1232
+ };
1233
+ }
1234
+ const description = reflowProse(raw);
1235
+ return description ? { description } : {};
1236
+ }
1237
+
1238
+ //#endregion
1239
+ //#region src/frontend/argtype/parser.ts
1240
+ /** Split a `///` block and attach its title/description to a node. Per the spec,
1241
+ * the block "is applied first and wins": for each field the block provides it
1242
+ * overrides a value a chained `.title()`/`.description()` already set, while a
1243
+ * field the block leaves unset keeps whatever the chain supplied. */
1244
+ function attachDocText(target, raw) {
1245
+ const { title, description } = splitDocText(raw);
1246
+ if (title !== void 0) target.title = title;
1247
+ if (description !== void 0) target.description = description;
1248
+ }
1249
+ const COMBINATORS = new Set([
1250
+ "seq",
1251
+ "set",
1252
+ "opt",
1253
+ "rep",
1254
+ "alt",
1255
+ "any"
1256
+ ]);
1257
+ const TERMINALS = new Set([
1258
+ "int",
1259
+ "float",
1260
+ "str",
1261
+ "path"
1262
+ ]);
1263
+ /** Core + supported-extension chaining methods. Anything else is an extension
1264
+ * we don't implement and is parsed-and-ignored (the spec's "ignorable" rule). */
1265
+ const KNOWN_METHODS = new Set([
1266
+ "name",
1267
+ "title",
1268
+ "description",
1269
+ "default",
1270
+ "min",
1271
+ "max",
1272
+ "join",
1273
+ "count",
1274
+ "countMin",
1275
+ "countMax",
1276
+ "mediaType",
1277
+ "mutable",
1278
+ "resolveParent"
1279
+ ]);
1280
+ /** Levenshtein edit distance between two strings. */
1281
+ function editDistance(a, b) {
1282
+ const m = a.length;
1283
+ const n = b.length;
1284
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
1285
+ for (let i = 1; i <= m; i++) {
1286
+ const cur = [i];
1287
+ for (let j = 1; j <= n; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
1288
+ prev = cur;
1289
+ }
1290
+ return prev[n];
1291
+ }
1292
+ /** The closest candidate to `name` within a small edit distance, for a "did you
1293
+ * mean?" hint on an unknown method. Returns undefined when nothing is close
1294
+ * enough, so a deliberate unknown-extension method gets no spurious suggestion.
1295
+ * A typo (`reqires`, `mediaTpe`) lands within distance 2 of a real method; an
1296
+ * unrelated extension method (`conflicts`) does not. The `bestDist < name.length`
1297
+ * clause additionally stops a very short unknown name from matching a longer
1298
+ * method by pure insertion (a 1-char `.n()` is not "did you mean `.min()`"). */
1299
+ function suggestMethod(name, candidates) {
1300
+ let best;
1301
+ let bestDist = Infinity;
1302
+ for (const c of candidates) {
1303
+ const d = editDistance(name, c);
1304
+ if (d < bestDist) {
1305
+ bestDist = d;
1306
+ best = c;
1307
+ }
1308
+ }
1309
+ return best !== void 0 && bestDist <= 2 && bestDist < name.length ? best : void 0;
1310
+ }
1311
+ var Parser = class {
1312
+ tokens;
1313
+ pos = 0;
1314
+ errors = [];
1315
+ warnings = [];
1316
+ constructor(tokens) {
1317
+ this.tokens = tokens;
1318
+ }
1319
+ peek(o = 0) {
1320
+ return this.tokens[Math.min(this.pos + o, this.tokens.length - 1)];
1321
+ }
1322
+ at(kind) {
1323
+ return this.peek().kind === kind;
1324
+ }
1325
+ next() {
1326
+ return this.tokens[this.pos++] ?? this.tokens[this.tokens.length - 1];
1327
+ }
1328
+ expect(kind, what) {
1329
+ if (this.at(kind)) return this.next();
1330
+ const tok = this.peek();
1331
+ this.error(`Expected ${what} but found '${tok.value || tok.kind}'`, tok);
1332
+ return tok;
1333
+ }
1334
+ error(message, tok = this.peek()) {
1335
+ this.errors.push({
1336
+ message,
1337
+ line: tok.line,
1338
+ column: tok.column
1339
+ });
1340
+ }
1341
+ warn(message, tok = this.peek()) {
1342
+ this.warnings.push({
1343
+ message,
1344
+ line: tok.line,
1345
+ column: tok.column
1346
+ });
1347
+ }
1348
+ parseDocument(frontmatter) {
1349
+ const aliases = [];
1350
+ let rootName;
1351
+ let root;
1352
+ while (!this.at("eof")) {
1353
+ const docs = this.collectDocs();
1354
+ if (this.at("eof")) break;
1355
+ const labelLike = this.at("ident") || this.at("string");
1356
+ const following = this.peek(1).kind;
1357
+ if (labelLike && following === "eq") {
1358
+ const nameTok = this.next();
1359
+ if (nameTok.kind === "string") this.error("Alias names must be identifiers, not quoted strings", nameTok);
1360
+ this.next();
1361
+ const expr = this.parseElement();
1362
+ if (docs) attachDocText(expr, docs);
1363
+ aliases.push({
1364
+ name: nameTok.value,
1365
+ expr
1366
+ });
1367
+ continue;
1368
+ }
1369
+ if (labelLike && following === "colon") {
1370
+ const nameTok = this.next();
1371
+ this.next();
1372
+ const expr = this.parseElement();
1373
+ if (docs) attachDocText(expr, docs);
1374
+ if (root) this.error(`Multiple root definitions; '${nameTok.value}' ignored (already have '${rootName ?? "<anonymous>"}')`, nameTok);
1375
+ else {
1376
+ rootName = nameTok.value;
1377
+ root = expr;
1378
+ if (!root.name) root.name = nameTok.value;
1379
+ }
1380
+ continue;
1381
+ }
1382
+ if (!this.at("ident") && !this.at("string") && !this.at("lparen")) {
1383
+ this.error("Expected a definition (name: expr), an alias (Name = expr), or a root expression");
1384
+ break;
1385
+ }
1386
+ const expr = this.parseElement();
1387
+ if (docs) attachDocText(expr, docs);
1388
+ if (root) {
1389
+ this.error("Multiple root definitions; a second top-level expression is not allowed");
1390
+ break;
1391
+ }
1392
+ root = expr;
1393
+ }
1394
+ if (!root) {
1395
+ this.error("No root definition (expected `name: expr` or a bare root expression)");
1396
+ return;
1397
+ }
1398
+ return {
1399
+ ...frontmatter && { frontmatter },
1400
+ aliases,
1401
+ ...rootName !== void 0 && { rootName },
1402
+ root
1403
+ };
1404
+ }
1405
+ /** Collect consecutive `///` doc lines, joined with newlines. */
1406
+ collectDocs() {
1407
+ const lines = [];
1408
+ while (this.at("doc")) lines.push(this.next().value);
1409
+ return lines.length > 0 ? lines.join("\n") : void 0;
1410
+ }
1411
+ /**
1412
+ * An element is the unit inside a comma list: an optionally-named expression.
1413
+ * `label:` is looser than `|`, so a name binds the whole alternative that
1414
+ * follows it.
1415
+ */
1416
+ parseElement() {
1417
+ if ((this.at("ident") || this.at("string")) && this.peek(1).kind === "colon") {
1418
+ const nameTok = this.next();
1419
+ this.next();
1420
+ const inner = this.parseElement();
1421
+ inner.name = nameTok.value;
1422
+ return inner;
1423
+ }
1424
+ return this.parseAlt();
1425
+ }
1426
+ parseAlt() {
1427
+ const first = this.parseChain();
1428
+ if (!this.at("pipe")) return first;
1429
+ const alts = [first];
1430
+ while (this.at("pipe")) {
1431
+ this.next();
1432
+ alts.push(this.parseChain());
1433
+ }
1434
+ return {
1435
+ kind: "comb",
1436
+ op: "alt",
1437
+ children: alts,
1438
+ ...first.span && { span: first.span }
1439
+ };
1440
+ }
1441
+ parseChain() {
1442
+ const node = this.parsePrimary();
1443
+ while (this.at("dot")) {
1444
+ this.next();
1445
+ this.applyMethod(node);
1446
+ }
1447
+ if (this.at("eq")) {
1448
+ this.next();
1449
+ node.default = this.parseValue();
1450
+ }
1451
+ return node;
1452
+ }
1453
+ parsePrimary() {
1454
+ const tok = this.peek();
1455
+ const span = {
1456
+ line: tok.line,
1457
+ column: tok.column
1458
+ };
1459
+ if (tok.kind === "string") {
1460
+ this.next();
1461
+ return {
1462
+ kind: "literal",
1463
+ value: tok.value,
1464
+ span
1465
+ };
1466
+ }
1467
+ if (tok.kind === "lparen") return {
1468
+ kind: "comb",
1469
+ op: "seq",
1470
+ children: this.parseParenList(),
1471
+ span
1472
+ };
1473
+ if (tok.kind === "ident") {
1474
+ if (COMBINATORS.has(tok.value) && this.peek(1).kind === "lparen") {
1475
+ this.next();
1476
+ const children = this.parseParenList();
1477
+ return {
1478
+ kind: "comb",
1479
+ op: tok.value,
1480
+ children,
1481
+ span
1482
+ };
1483
+ }
1484
+ if (TERMINALS.has(tok.value)) {
1485
+ this.next();
1486
+ return {
1487
+ kind: "terminal",
1488
+ terminal: tok.value,
1489
+ span
1490
+ };
1491
+ }
1492
+ this.next();
1493
+ return {
1494
+ kind: "ref",
1495
+ refName: tok.value,
1496
+ span
1497
+ };
1498
+ }
1499
+ this.error(`Unexpected token '${tok.value || tok.kind}' in expression`, tok);
1500
+ this.next();
1501
+ return {
1502
+ kind: "literal",
1503
+ value: "",
1504
+ span
1505
+ };
1506
+ }
1507
+ /** Parse `( elem, elem, ... )` with optional leading docs and trailing comma. */
1508
+ parseParenList() {
1509
+ this.expect("lparen", "'('");
1510
+ const items = [];
1511
+ while (!this.at("rparen") && !this.at("eof")) {
1512
+ const docs = this.collectDocs();
1513
+ if (this.at("rparen")) break;
1514
+ const elem = this.parseElement();
1515
+ if (docs) attachDocText(elem, docs);
1516
+ items.push(elem);
1517
+ if (this.at("comma")) this.next();
1518
+ else break;
1519
+ }
1520
+ this.expect("rparen", "')'");
1521
+ return items;
1522
+ }
1523
+ applyMethod(node) {
1524
+ const nameTok = this.expect("ident", "a method name");
1525
+ const method = nameTok.value;
1526
+ if (method === "output") {
1527
+ node.outputs = [...node.outputs ?? [], ...this.parseOutputArgs()];
1528
+ return;
1529
+ }
1530
+ if (!KNOWN_METHODS.has(method)) {
1531
+ this.skipBalancedArgs();
1532
+ const hint = suggestMethod(method, [...KNOWN_METHODS, "output"]);
1533
+ this.warn(`Ignoring unsupported method '.${method}()'` + (hint ? ` (did you mean '.${hint}()'?)` : ""), nameTok);
1534
+ return;
1535
+ }
1536
+ const args = this.parseScalarArgs();
1537
+ switch (method) {
1538
+ case "name":
1539
+ if (typeof args[0] === "string") node.name = args[0];
1540
+ break;
1541
+ case "title":
1542
+ if (typeof args[0] === "string") node.title = args[0];
1543
+ break;
1544
+ case "description":
1545
+ if (typeof args[0] === "string") node.description = args[0];
1546
+ break;
1547
+ case "default":
1548
+ if (args[0] !== void 0) node.default = args[0];
1549
+ break;
1550
+ case "min":
1551
+ if (typeof args[0] === "number") node.min = args[0];
1552
+ break;
1553
+ case "max":
1554
+ if (typeof args[0] === "number") node.max = args[0];
1555
+ break;
1556
+ case "join":
1557
+ node.join = typeof args[0] === "string" ? args[0] : "";
1558
+ break;
1559
+ case "count":
1560
+ if (typeof args[0] === "number") {
1561
+ node.countMin = args[0];
1562
+ node.countMax = args[0];
1563
+ }
1564
+ break;
1565
+ case "countMin":
1566
+ if (typeof args[0] === "number") node.countMin = args[0];
1567
+ break;
1568
+ case "countMax":
1569
+ if (typeof args[0] === "number") node.countMax = args[0];
1570
+ break;
1571
+ case "mediaType":
1572
+ if (typeof args[0] === "string") (node.mediaTypes ??= []).push(args[0]);
1573
+ break;
1574
+ case "mutable":
1575
+ node.mutable = true;
1576
+ break;
1577
+ case "resolveParent":
1578
+ node.resolveParent = true;
1579
+ break;
1580
+ }
1581
+ }
1582
+ /** Parse a parenthesized list of plain scalar arguments (numbers / strings). */
1583
+ parseScalarArgs() {
1584
+ this.expect("lparen", "'('");
1585
+ const args = [];
1586
+ while (!this.at("rparen") && !this.at("eof")) {
1587
+ args.push(this.parseValue());
1588
+ if (this.at("comma")) this.next();
1589
+ else break;
1590
+ }
1591
+ this.expect("rparen", "')'");
1592
+ return args;
1593
+ }
1594
+ /** Consume a balanced `( ... )` group without interpreting its contents.
1595
+ * Used to skip the arguments of an unsupported extension method. */
1596
+ skipBalancedArgs() {
1597
+ if (!this.at("lparen")) return;
1598
+ let depth = 0;
1599
+ do {
1600
+ const tok = this.next();
1601
+ if (tok.kind === "lparen") depth++;
1602
+ else if (tok.kind === "rparen") depth--;
1603
+ else if (tok.kind === "eof") break;
1604
+ } while (depth > 0);
1605
+ }
1606
+ /** Parse the argument list of `.output(...)`: one or more template expressions. */
1607
+ parseOutputArgs() {
1608
+ this.expect("lparen", "'('");
1609
+ const outputs = [];
1610
+ while (!this.at("rparen") && !this.at("eof")) {
1611
+ const docs = this.collectDocs();
1612
+ if (this.at("rparen")) break;
1613
+ const out = this.parseOutputTemplate();
1614
+ if (docs) attachDocText(out, docs);
1615
+ outputs.push(out);
1616
+ if (this.at("comma")) this.next();
1617
+ else break;
1618
+ }
1619
+ this.expect("rparen", "')'");
1620
+ return outputs;
1621
+ }
1622
+ /** `label: \`tpl\`` or `\`tpl\`` followed by optional `.name(...)` / `.or(...)`. */
1623
+ parseOutputTemplate() {
1624
+ let name;
1625
+ if ((this.at("ident") || this.at("string")) && this.peek(1).kind === "colon") {
1626
+ name = this.next().value;
1627
+ this.next();
1628
+ }
1629
+ const out = { tokens: [] };
1630
+ if (this.at("template")) {
1631
+ const tmplTok = this.next();
1632
+ const { tokens, errors } = parseTemplate(tmplTok.value);
1633
+ out.tokens = tokens;
1634
+ for (const e of errors) this.error(e, tmplTok);
1635
+ } else this.error(`Expected an output template literal`);
1636
+ const CHAIN = new Set([
1637
+ "name",
1638
+ "or",
1639
+ "title",
1640
+ "description"
1641
+ ]);
1642
+ while (this.at("dot")) {
1643
+ this.next();
1644
+ const m = this.expect("ident", "a template method");
1645
+ if (CHAIN.has(m.value)) {
1646
+ const arg = this.parseScalarArgs()[0];
1647
+ if (typeof arg === "string") if (m.value === "name") name = arg;
1648
+ else if (m.value === "or") out.fallback = arg;
1649
+ else if (m.value === "title") out.title = arg;
1650
+ else out.description = arg;
1651
+ } else {
1652
+ this.skipBalancedArgs();
1653
+ const hint = suggestMethod(m.value, CHAIN);
1654
+ this.warn(`Ignoring unsupported output-template method '.${m.value}()'` + (hint ? ` (did you mean '.${hint}()'?)` : ""), m);
1655
+ }
1656
+ }
1657
+ if (name !== void 0) out.name = name;
1658
+ return out;
1659
+ }
1660
+ parseValue() {
1661
+ const tok = this.peek();
1662
+ if (tok.kind === "string") {
1663
+ this.next();
1664
+ return tok.value;
1665
+ }
1666
+ if (tok.kind === "number") {
1667
+ this.next();
1668
+ return Number(tok.value);
1669
+ }
1670
+ this.error(`Expected a value (string or number) but found '${tok.value || tok.kind}'`, tok);
1671
+ this.next();
1672
+ return "";
1673
+ }
1674
+ };
1675
+ function parseArgtype(source) {
1676
+ const { frontmatter, body, errors: fmErrors } = splitFrontmatter(source);
1677
+ const { tokens, errors: lexErrors } = lex(body);
1678
+ const parser = new Parser(tokens);
1679
+ const doc = parser.parseDocument(frontmatter);
1680
+ const errors = [
1681
+ ...fmErrors.map((m) => ({ message: m })),
1682
+ ...lexErrors.map((e) => ({
1683
+ message: e.message,
1684
+ line: e.line,
1685
+ column: e.column
1686
+ })),
1687
+ ...parser.errors
1688
+ ];
1689
+ return {
1690
+ ...doc && { doc },
1691
+ errors,
1692
+ warnings: parser.warnings
1693
+ };
1694
+ }
1695
+
1696
+ //#endregion
1697
+ //#region src/ir/builders.ts
1698
+ function lit(str) {
1699
+ return {
1700
+ kind: "literal",
1701
+ attrs: { str }
1702
+ };
1703
+ }
1704
+ function str(meta) {
1705
+ return {
1706
+ kind: "str",
1707
+ attrs: {},
1708
+ meta: normalizeMeta(meta)
1709
+ };
1710
+ }
1711
+ function int(meta) {
1712
+ return {
1713
+ kind: "int",
1714
+ attrs: {},
1715
+ meta: normalizeMeta(meta)
1716
+ };
1717
+ }
1718
+ function float(meta) {
1719
+ return {
1720
+ kind: "float",
1721
+ attrs: {},
1722
+ meta: normalizeMeta(meta)
1723
+ };
1724
+ }
1725
+ function path(meta) {
1726
+ return {
1727
+ kind: "path",
1728
+ attrs: {},
1729
+ meta: normalizeMeta(meta)
1730
+ };
1731
+ }
1732
+ function seq(...nodes) {
1733
+ return {
1734
+ kind: "sequence",
1735
+ attrs: { nodes }
1736
+ };
1737
+ }
1738
+ function seqJoin(join, ...nodes) {
1739
+ return {
1740
+ kind: "sequence",
1741
+ attrs: {
1742
+ nodes,
1743
+ join
1744
+ }
1745
+ };
1746
+ }
1747
+ function opt(node, meta) {
1748
+ return {
1749
+ kind: "optional",
1750
+ attrs: { node },
1751
+ meta: normalizeMeta(meta)
1752
+ };
1753
+ }
1754
+ function rep(node, meta) {
1755
+ return {
1756
+ kind: "repeat",
1757
+ attrs: { node },
1758
+ meta: normalizeMeta(meta)
1759
+ };
1760
+ }
1761
+ function repJoin(join, node, meta) {
1762
+ return {
1763
+ kind: "repeat",
1764
+ attrs: {
1765
+ node,
1766
+ join
1767
+ },
1768
+ meta: normalizeMeta(meta)
1769
+ };
1770
+ }
1771
+ function alt(...alts) {
1772
+ return {
1773
+ kind: "alternative",
1774
+ attrs: { alts }
1775
+ };
1776
+ }
1777
+ function normalizeMeta(meta) {
1778
+ if (meta === void 0) return void 0;
1779
+ if (typeof meta === "string") return { name: meta };
1780
+ return meta;
1781
+ }
1782
+
1783
+ //#endregion
1784
+ //#region src/ir/meta.ts
1785
+ /** Construct a NodeRef from a node name. */
1786
+ function nodeRef(name) {
1787
+ return {
1788
+ kind: "node-ref",
1789
+ name
1790
+ };
1791
+ }
1792
+ /**
1793
+ * Produce a usable name for an Output. Frontends may leave `Output.name`
1794
+ * unset; downstream code (resolver, validator, backends) needs a stable
1795
+ * identifier for diagnostics and field naming. Falls back to `output_<index>`
1796
+ * keyed by the output's position in tree-walk order.
1797
+ */
1798
+ function effectiveOutputName(output, index) {
1799
+ return output.name ?? `output_${index}`;
1800
+ }
1801
+
1802
+ //#endregion
1803
+ //#region src/frontend/argtype/lower.ts
1804
+ /**
1805
+ * Lower an `AstDocument` to Styx IR (`Expr` + `AppMeta`).
1806
+ *
1807
+ * Mapping highlights:
1808
+ * - Combinators map 1:1 except `set` -> `sequence` (order-not-meaningful is not
1809
+ * modeled in the IR) and `any` -> its first branch (the spec's "emit branch 0"
1810
+ * rule; the interchangeable alternatives are dropped with a warning).
1811
+ * - Aliases are resolved by substitution with cycle detection.
1812
+ * - `.output(...)` declarations attach to the nearest enclosing sequence scope,
1813
+ * so an output nested in a repeat / subcommand keeps its list / struct shape
1814
+ * (per-output gating is recovered downstream from each ref binding's gate).
1815
+ */
1816
+ /** Spread a node's span into a diagnostic (no-op when the node has no span). */
1817
+ function at(span) {
1818
+ return span ? {
1819
+ line: span.line,
1820
+ column: span.column
1821
+ } : {};
1822
+ }
1823
+ /** Build IR `Documentation` from an AST node's already-split title/description. */
1824
+ function docFrom(node) {
1825
+ const doc = {
1826
+ ...node.title && { title: node.title },
1827
+ ...node.description && { description: node.description }
1828
+ };
1829
+ return Object.keys(doc).length > 0 ? doc : void 0;
1830
+ }
1831
+ var Lowerer = class {
1832
+ errors = [];
1833
+ warnings = [];
1834
+ aliases = /* @__PURE__ */ new Map();
1835
+ constructor(aliases) {
1836
+ for (const a of aliases) {
1837
+ if (this.aliases.has(a.name)) this.warn(`Duplicate alias '${a.name}'; last definition wins`, a.expr);
1838
+ this.aliases.set(a.name, a.expr);
1839
+ }
1840
+ }
1841
+ /** Record an error, located at `node`'s span when one is available. */
1842
+ err(message, node) {
1843
+ this.errors.push({
1844
+ message,
1845
+ ...at(node?.span)
1846
+ });
1847
+ }
1848
+ /** Record a warning, located at `node`'s span when one is available. */
1849
+ warn(message, node) {
1850
+ this.warnings.push({
1851
+ message,
1852
+ ...at(node?.span)
1853
+ });
1854
+ }
1855
+ lower(doc) {
1856
+ const rootSink = [];
1857
+ const expr = this.lowerNode(doc.root, /* @__PURE__ */ new Set(), void 0, rootSink);
1858
+ const root = expr.kind === "sequence" ? expr : seq(expr);
1859
+ if (root !== expr) root.meta = { ...doc.root.name && { name: doc.root.name } };
1860
+ if (rootSink.length > 0) root.meta = {
1861
+ ...root.meta,
1862
+ outputs: [...root.meta?.outputs ?? [], ...rootSink]
1863
+ };
1864
+ return {
1865
+ expr: root,
1866
+ errors: this.errors,
1867
+ warnings: this.warnings
1868
+ };
1869
+ }
1870
+ /**
1871
+ * @param expanding - alias names currently being expanded (cycle guard).
1872
+ * @param selfName - nearest enclosing named node, for `{}` self-references.
1873
+ * @param sink - outputs array of the nearest enclosing sequence; a `.output()`
1874
+ * on this node attaches here (seq/set nodes instead own their outputs).
1875
+ */
1876
+ lowerNode(node, expanding, selfName, sink) {
1877
+ if (node.kind === "ref") return this.lowerRef(node, expanding, selfName, sink);
1878
+ const name = node.name ?? selfName;
1879
+ const joinable = node.kind === "comb" && (node.op === "seq" || node.op === "set" || node.op === "rep" || node.op === "opt");
1880
+ if (node.join !== void 0 && !joinable) this.err("`.join()` is only supported on seq/set/rep/opt", node);
1881
+ const isNumericTerminal = node.kind === "terminal" && (node.terminal === "int" || node.terminal === "float");
1882
+ const isRep = node.kind === "comb" && node.op === "rep";
1883
+ const isPath = node.kind === "terminal" && node.terminal === "path";
1884
+ if ((node.min !== void 0 || node.max !== void 0) && !isNumericTerminal) this.err("`.min()`/`.max()` is only supported on int/float terminals", node);
1885
+ if ((node.countMin !== void 0 || node.countMax !== void 0) && !isRep) this.err("`.count()`/`.countMin()`/`.countMax()` is only supported on rep", node);
1886
+ if ((node.mutable || node.resolveParent) && !isPath) this.err("`.mutable()`/`.resolveParent()` is only supported on path", node);
1887
+ if (node.mediaTypes?.length && !isPath) this.err("`.mediaType()` is only supported on path", node);
1888
+ const isStruct = node.kind === "comb" && (node.op === "seq" || node.op === "set");
1889
+ if (node.default !== void 0 && isStruct) {
1890
+ this.warn("`= value` / `.default()` is not supported on a seq/set struct; ignored", node);
1891
+ node.default = void 0;
1892
+ }
1893
+ const isSeqSet = node.kind === "comb" && (node.op === "seq" || node.op === "set");
1894
+ if (node.outputs?.length && !isSeqSet) for (const o of node.outputs) sink.push(this.toOutput(o, name));
1895
+ switch (node.kind) {
1896
+ case "literal": {
1897
+ const e = lit(node.value ?? "");
1898
+ this.applyMeta(e, node);
1899
+ return e;
1900
+ }
1901
+ case "terminal": return this.lowerTerminal(node);
1902
+ case "comb": return this.lowerComb(node, expanding, name, sink);
1903
+ default:
1904
+ this.err(`Unknown AST node kind '${node.kind}'`, node);
1905
+ return seq();
1906
+ }
1907
+ }
1908
+ lowerRef(node, expanding, selfName, sink) {
1909
+ const target = node.refName;
1910
+ const aliasExpr = this.aliases.get(target);
1911
+ if (!aliasExpr) {
1912
+ this.err(`Unknown alias '${target}'`, node);
1913
+ return seq();
1914
+ }
1915
+ if (expanding.has(target)) {
1916
+ this.err(`Recursive alias '${target}' is not allowed`, node);
1917
+ return seq();
1918
+ }
1919
+ const clone = structuredClone(aliasExpr);
1920
+ clone.span = node.span ?? clone.span;
1921
+ clone.name = node.name ?? clone.name;
1922
+ clone.title = node.title ?? clone.title;
1923
+ clone.description = node.description ?? clone.description;
1924
+ if (node.default !== void 0) clone.default = node.default;
1925
+ if (node.min !== void 0) clone.min = node.min;
1926
+ if (node.max !== void 0) clone.max = node.max;
1927
+ if (node.join !== void 0) clone.join = node.join;
1928
+ if (node.countMin !== void 0) clone.countMin = node.countMin;
1929
+ if (node.countMax !== void 0) clone.countMax = node.countMax;
1930
+ if (node.mediaTypes?.length) clone.mediaTypes = [...clone.mediaTypes ?? [], ...node.mediaTypes];
1931
+ if (node.mutable) clone.mutable = true;
1932
+ if (node.resolveParent) clone.resolveParent = true;
1933
+ if (node.outputs?.length) clone.outputs = [...clone.outputs ?? [], ...node.outputs];
1934
+ const next = new Set(expanding);
1935
+ next.add(target);
1936
+ return this.lowerNode(clone, next, selfName, sink);
1937
+ }
1938
+ lowerTerminal(node) {
1939
+ switch (node.terminal) {
1940
+ case "int": {
1941
+ const e = int();
1942
+ this.checkBounds(node.min, node.max, "value", "min", "max", node);
1943
+ if (node.min !== void 0) e.attrs.minValue = node.min;
1944
+ if (node.max !== void 0) e.attrs.maxValue = node.max;
1945
+ this.applyMeta(e, node);
1946
+ return e;
1947
+ }
1948
+ case "float": {
1949
+ const e = float();
1950
+ this.checkBounds(node.min, node.max, "value", "min", "max", node);
1951
+ if (node.min !== void 0) e.attrs.minValue = node.min;
1952
+ if (node.max !== void 0) e.attrs.maxValue = node.max;
1953
+ this.applyMeta(e, node);
1954
+ return e;
1955
+ }
1956
+ case "str": {
1957
+ const e = str();
1958
+ this.applyMeta(e, node);
1959
+ return e;
1960
+ }
1961
+ case "path": {
1962
+ const e = path();
1963
+ if (node.mediaTypes?.length) e.attrs.mediaTypes = node.mediaTypes;
1964
+ if (node.mutable) e.attrs.mutable = true;
1965
+ if (node.resolveParent) e.attrs.resolveParent = true;
1966
+ this.applyMeta(e, node);
1967
+ return e;
1968
+ }
1969
+ default:
1970
+ this.err(`Unknown terminal '${String(node.terminal)}'`, node);
1971
+ return str();
1972
+ }
1973
+ }
1974
+ lowerComb(node, expanding, name, sink) {
1975
+ const children = node.children ?? [];
1976
+ const lowerChildren = (s) => children.map((c) => this.lowerNode(c, expanding, name, s));
1977
+ switch (node.op) {
1978
+ case "seq":
1979
+ case "set": {
1980
+ const selfOutputs = [];
1981
+ for (const o of node.outputs ?? []) selfOutputs.push(this.toOutput(o, name));
1982
+ const lowered = lowerChildren(selfOutputs);
1983
+ this.dedupeSiblingNames(lowered, node);
1984
+ const e = seq(...lowered);
1985
+ if (node.join !== void 0) e.attrs.join = node.join;
1986
+ this.applyMeta(e, node);
1987
+ if (selfOutputs.length > 0) e.meta = {
1988
+ ...e.meta,
1989
+ outputs: [...e.meta?.outputs ?? [], ...selfOutputs]
1990
+ };
1991
+ return e;
626
1992
  }
627
- const alt = {
628
- kind: "alternative",
629
- attrs: { alts: altMembers }
630
- };
631
- const isRequired = group.required === true;
632
- let groupNode;
633
- if (isRequired) groupNode = alt;
634
- else groupNode = {
635
- kind: "optional",
636
- attrs: { node: alt }
637
- };
638
- const groupName = (isString$3(group.title) ? group.title : void 0) ?? (memberDests.length === 2 ? `${memberDests[0]}_or_${memberDests[1]}` : `${memberDests[0]}_choice`);
639
- groupNode.meta = {
640
- ...groupNode.meta,
641
- name: groupName
642
- };
643
- const firstDest = memberDests[0];
644
- const firstIdx = nodes.findIndex((n) => n === nodesByDest.get(firstDest));
645
- if (firstIdx >= 0) nodes.splice(firstIdx, 0, groupNode);
646
- else nodes.push(groupNode);
1993
+ case "alt": {
1994
+ const arms = lowerChildren(sink);
1995
+ this.dedupeSiblingNames(arms, node);
1996
+ const e = alt(...arms);
1997
+ this.applyMeta(e, node);
1998
+ return e;
1999
+ }
2000
+ case "any": {
2001
+ if (children.length === 0) {
2002
+ this.err("`any(...)` requires at least one branch", node);
2003
+ return seq();
2004
+ }
2005
+ const e = this.lowerNode(children[0], expanding, name, sink);
2006
+ if (node.name) e.meta = {
2007
+ ...e.meta,
2008
+ name: node.name
2009
+ };
2010
+ {
2011
+ const d = docFrom(node);
2012
+ if (d) e.meta = {
2013
+ ...e.meta,
2014
+ doc: d
2015
+ };
2016
+ }
2017
+ return e;
2018
+ }
2019
+ case "opt": {
2020
+ const inner = this.wrapChildren(children, expanding, name, sink);
2021
+ if (node.join !== void 0 && (inner.kind === "sequence" || inner.kind === "repeat")) inner.attrs.join = node.join;
2022
+ const e = opt(inner);
2023
+ this.applyMeta(e, node);
2024
+ if (inner.kind === "literal") e.meta = {
2025
+ ...e.meta,
2026
+ defaultValue: false
2027
+ };
2028
+ return e;
2029
+ }
2030
+ case "rep": {
2031
+ const e = rep(this.wrapChildren(children, expanding, name, sink));
2032
+ if (node.join !== void 0) e.attrs.join = node.join;
2033
+ this.checkBounds(node.countMin, node.countMax, "repetition count", "countMin", "countMax", node);
2034
+ if (node.countMin !== void 0) e.attrs.countMin = node.countMin;
2035
+ if (node.countMax !== void 0) e.attrs.countMax = node.countMax;
2036
+ this.applyMeta(e, node);
2037
+ return e;
2038
+ }
2039
+ default:
2040
+ this.err(`Unknown combinator '${String(node.op)}'`, node);
2041
+ return seq();
647
2042
  }
648
- return nodes.filter((n) => {
649
- for (const [dest, node] of nodesByDest) if (node === n && excluded.has(dest)) return false;
650
- return true;
651
- });
652
2043
  }
653
- parseParserInfo(descriptor) {
654
- const actions = descriptor.actions;
655
- if (!isArray$3(actions)) return {
656
- kind: "sequence",
657
- attrs: { nodes: [] }
2044
+ /** Warn on an inverted `[min, max]` pair (a lower bound above its upper
2045
+ * bound), which yields an unsatisfiable constraint downstream. */
2046
+ checkBounds(min, max, what, minLabel, maxLabel, node) {
2047
+ if (min !== void 0 && max !== void 0 && min > max) this.warn(`Inverted ${what} bounds: ${minLabel} (${min}) > ${maxLabel} (${max})`, node);
2048
+ }
2049
+ /**
2050
+ * Disambiguate duplicate explicit names among the direct children of a
2051
+ * sequence/set (struct fields) or the arms of an alternative (union
2052
+ * discriminants). The solver turns each named child into a struct field / union
2053
+ * variant keyed by its name; two siblings with the same name would silently
2054
+ * collapse (the second overwrites the first, dropping a parameter). argtype is
2055
+ * hand-authored
2056
+ * and gives no uniqueness guarantee, so - like the mrtrix frontend - we rename
2057
+ * collisions (`name_2`, `name_3`, ...) and warn. (This catches duplicate
2058
+ * explicit labels; it does not detect collisions between solver-derived names
2059
+ * of otherwise-unnamed children, which the solver also does not guard.)
2060
+ */
2061
+ dedupeSiblingNames(children, parent) {
2062
+ const used = /* @__PURE__ */ new Set();
2063
+ for (const child of children) {
2064
+ const nm = child.meta?.name;
2065
+ if (nm === void 0) continue;
2066
+ if (!used.has(nm)) {
2067
+ used.add(nm);
2068
+ continue;
2069
+ }
2070
+ let n = 2;
2071
+ while (used.has(`${nm}_${n}`)) n++;
2072
+ const renamed = `${nm}_${n}`;
2073
+ used.add(renamed);
2074
+ child.meta = {
2075
+ ...child.meta,
2076
+ name: renamed
2077
+ };
2078
+ this.warn(`Duplicate sibling name '${nm}'; renamed one occurrence to '${renamed}'`, parent);
2079
+ }
2080
+ }
2081
+ /** `opt`/`rep` with multiple children implicitly wrap them in a sequence,
2082
+ * which - like any sequence - is the output scope for those children. */
2083
+ wrapChildren(children, expanding, name, sink) {
2084
+ if (children.length === 1) return this.lowerNode(children[0], expanding, name, sink);
2085
+ const selfOutputs = [];
2086
+ const e = seq(...children.map((c) => this.lowerNode(c, expanding, name, selfOutputs)));
2087
+ if (selfOutputs.length > 0) e.meta = {
2088
+ ...e.meta,
2089
+ outputs: selfOutputs
658
2090
  };
659
- const positionals = [];
660
- const optionals = [];
661
- const actionsByDest = /* @__PURE__ */ new Map();
662
- const nodesByDest = /* @__PURE__ */ new Map();
663
- for (const rawAction of actions) {
664
- if (!isObject$3(rawAction)) continue;
665
- const node = this.buildAction(rawAction);
666
- if (!node) continue;
667
- const dest = rawAction.dest;
668
- if (isString$3(dest)) {
669
- actionsByDest.set(dest, rawAction);
670
- nodesByDest.set(dest, node);
2091
+ return e;
2092
+ }
2093
+ /** Fold an AST node's name/doc/default decorations onto an IR node's meta. */
2094
+ applyMeta(e, node) {
2095
+ const meta = {};
2096
+ if (node.name) meta.name = node.name;
2097
+ const doc = docFrom(node);
2098
+ if (doc) meta.doc = doc;
2099
+ if (node.default !== void 0) meta.defaultValue = node.default;
2100
+ if (Object.keys(meta).length > 0) e.meta = {
2101
+ ...e.meta,
2102
+ ...meta
2103
+ };
2104
+ }
2105
+ toOutput(o, selfName) {
2106
+ const tokens = [];
2107
+ for (const t of o.tokens) {
2108
+ if (t.kind === "literal") {
2109
+ tokens.push({
2110
+ kind: "literal",
2111
+ value: t.value
2112
+ });
2113
+ continue;
671
2114
  }
672
- if (this.isPositional(rawAction) && rawAction.action_type !== "parsers") positionals.push(node);
673
- else optionals.push(node);
2115
+ const targetName = t.name ?? selfName;
2116
+ if (!targetName) {
2117
+ this.warn("Output self-reference '{}' has no named node to resolve to; emitting empty");
2118
+ tokens.push({
2119
+ kind: "literal",
2120
+ value: ""
2121
+ });
2122
+ continue;
2123
+ }
2124
+ if (t.stripPrefix?.length) this.warn("Output ref op 'strip_prefix' is not supported by the IR; ignored");
2125
+ if (t.basename) this.warn("Output ref op 'basename' is not supported by the IR; ignored");
2126
+ tokens.push({
2127
+ kind: "ref",
2128
+ target: nodeRef(targetName),
2129
+ ...t.stripSuffix?.length && { stripExtensions: t.stripSuffix },
2130
+ ...t.or !== void 0 && { fallback: t.or }
2131
+ });
674
2132
  }
675
- let allNodes = [...positionals, ...optionals];
676
- const mutexGroups = descriptor.mutually_exclusive_groups;
677
- if (isArray$3(mutexGroups) && mutexGroups.length > 0) allNodes = this.applyMutualExclusion(actionsByDest, mutexGroups, allNodes, nodesByDest);
2133
+ if (o.fallback !== void 0) this.warn("Output-level '.or(...)' fallback is not supported by the IR; ignored");
2134
+ const doc = docFrom(o);
678
2135
  return {
679
- kind: "sequence",
680
- attrs: { nodes: allNodes }
2136
+ ...o.name && { name: o.name },
2137
+ ...doc && { doc },
2138
+ tokens
681
2139
  };
682
2140
  }
683
- parse(source, _filename) {
684
- this.reset();
685
- const descriptor = this.parseJSON(source);
686
- if (descriptor === null) return {
687
- expr: {
688
- kind: "sequence",
689
- attrs: { nodes: [] }
690
- },
691
- errors: this.errors,
692
- warnings: this.warnings
2141
+ };
2142
+ function buildAppMeta(fm, rootDoc, rootName) {
2143
+ const warnings = [];
2144
+ const rootTitleDesc = docFrom(rootDoc);
2145
+ if (!fm) {
2146
+ const meta = {
2147
+ ...rootName && { id: rootName },
2148
+ ...rootTitleDesc && { doc: rootTitleDesc }
693
2149
  };
694
- const meta = this.buildAppMeta(descriptor);
695
- if (!meta) {
696
- this.error("Descriptor is missing prog");
697
- return {
698
- expr: {
699
- kind: "sequence",
700
- attrs: { nodes: [] }
701
- },
702
- errors: this.errors,
703
- warnings: this.warnings
704
- };
705
- }
706
- const expr = this.parseParserInfo(descriptor);
707
- if (expr === null) {
708
- this.error("Failed to parse argument structure");
709
- return {
710
- expr: {
711
- kind: "sequence",
712
- attrs: { nodes: [] }
713
- },
714
- errors: this.errors,
715
- warnings: this.warnings
716
- };
717
- }
718
- const prog = descriptor.prog;
719
- if (isString$3(prog) && prog) expr.attrs.nodes.unshift({
720
- kind: "literal",
721
- attrs: { str: prog }
722
- });
723
- if (!expr.meta?.name && meta.id) expr.meta = {
724
- ...expr.meta,
725
- name: meta.id
2150
+ return Object.keys(meta).length > 0 ? {
2151
+ meta,
2152
+ warnings
2153
+ } : { warnings };
2154
+ }
2155
+ const asStr = (x) => typeof x === "string" && x.length > 0 ? x : void 0;
2156
+ const id = asStr(rootName) ?? asStr(fm.exe) ?? asStr(fm.id);
2157
+ const version = asStr(fm.version) ?? (typeof fm.version === "number" ? String(fm.version) : void 0);
2158
+ const strList = (x) => Array.isArray(x) ? x.filter((a) => typeof a === "string") : [];
2159
+ const authors = strList(fm.authors);
2160
+ const urls = strList(fm.urls);
2161
+ const references = strList(fm.references);
2162
+ const checkList = (key, v) => {
2163
+ if (v !== void 0 && v !== null && !Array.isArray(v)) warnings.push(`Frontmatter '${key}' should be a list; got a scalar - ignored`);
2164
+ };
2165
+ checkList("authors", fm.authors);
2166
+ checkList("urls", fm.urls);
2167
+ checkList("references", fm.references);
2168
+ if (fm.version !== void 0 && fm.version !== null && version === void 0) warnings.push(`Frontmatter 'version' should be a string or number; ignored`);
2169
+ if (fm.container !== void 0 && fm.container !== null) {
2170
+ if (!isRecord$1(fm.container)) warnings.push(`Frontmatter 'container' should be a mapping with an 'image'; ignored`);
2171
+ else if (!asStr(fm.container.image)) warnings.push(`Frontmatter 'container' is missing an 'image'; ignored`);
2172
+ }
2173
+ const doc = {
2174
+ ...rootTitleDesc,
2175
+ ...authors.length > 0 && { authors },
2176
+ ...urls.length > 0 && { urls },
2177
+ ...references.length > 0 && { literature: references }
2178
+ };
2179
+ const meta = {
2180
+ ...id && { id },
2181
+ ...version && { version },
2182
+ ...Object.keys(doc).length > 0 && { doc }
2183
+ };
2184
+ if (isRecord$1(fm.container)) {
2185
+ const image = asStr(fm.container.image);
2186
+ const type = asStr(fm.container.type);
2187
+ if (image) meta.container = {
2188
+ image,
2189
+ ...type === "docker" || type === "singularity" ? { type } : {}
726
2190
  };
2191
+ }
2192
+ meta.stdout = streamFrom(fm.stdout, warnings, "stdout");
2193
+ meta.stderr = streamFrom(fm.stderr, warnings, "stderr");
2194
+ if (!meta.stdout) delete meta.stdout;
2195
+ if (!meta.stderr) delete meta.stderr;
2196
+ return {
2197
+ meta,
2198
+ warnings
2199
+ };
2200
+ }
2201
+ function streamFrom(x, warnings, key) {
2202
+ if (x === void 0 || x === null) return void 0;
2203
+ if (isRecord$1(x)) {
2204
+ const name = typeof x.name === "string" ? x.name : void 0;
2205
+ if (!name) {
2206
+ warnings.push(`Frontmatter '${key}' is missing a 'name'; ignored`);
2207
+ return;
2208
+ }
2209
+ const description = typeof x.description === "string" ? x.description : void 0;
727
2210
  return {
728
- meta,
729
- expr,
730
- errors: this.errors,
731
- warnings: this.warnings
2211
+ name,
2212
+ ...description && { doc: { description } }
732
2213
  };
733
2214
  }
734
- };
2215
+ if (typeof x === "string") return { name: x };
2216
+ warnings.push(`Frontmatter '${key}' has an unexpected shape; ignored`);
2217
+ }
2218
+ function isRecord$1(x) {
2219
+ return typeof x === "object" && x !== null && !Array.isArray(x);
2220
+ }
2221
+ function lowerDocument(doc) {
2222
+ const result = new Lowerer(doc.aliases).lower(doc);
2223
+ const exe = typeof doc.frontmatter?.exe === "string" ? doc.frontmatter.exe : void 0;
2224
+ if (exe) result.expr.attrs.nodes.unshift(lit(exe));
2225
+ const { meta, warnings } = buildAppMeta(doc.frontmatter, {
2226
+ title: doc.root.title,
2227
+ description: doc.root.description
2228
+ }, doc.rootName);
2229
+ return {
2230
+ ...meta && { meta },
2231
+ expr: result.expr,
2232
+ errors: result.errors,
2233
+ warnings: [...result.warnings, ...warnings.map((message) => ({ message }))]
2234
+ };
2235
+ }
735
2236
 
736
2237
  //#endregion
737
- //#region src/ir/meta.ts
738
- /** Construct a NodeRef from a node name. */
739
- function nodeRef(name) {
2238
+ //#region src/frontend/argtype/parser-frontend.ts
2239
+ /** A fresh empty root sequence for error returns (IR passes mutate in place). */
2240
+ function emptyExpr$2() {
740
2241
  return {
741
- kind: "node-ref",
742
- name
2242
+ kind: "sequence",
2243
+ attrs: { nodes: [] }
743
2244
  };
744
2245
  }
745
2246
  /**
746
- * Produce a usable name for an Output. Frontends may leave `Output.name`
747
- * unset; downstream code (resolver, validator, backends) needs a stable
748
- * identifier for diagnostics and field naming. Falls back to `output_<index>`
749
- * keyed by the output's position in tree-walk order.
2247
+ * Frontend for the argtype sugar DSL - the hand-authored, TypeScript-types-like
2248
+ * language for describing CLI argument grammars (see the argtype spec). Parses
2249
+ * the text into an AST, then lowers it to Styx IR + AppMeta.
2250
+ *
2251
+ * Supported today: the argtype core (combinators, terminals, literals, naming,
2252
+ * aliases, value constraints, `.join`/`.count`/`.default`, doc comments,
2253
+ * frontmatter) plus the `outputs`, `mediatypes`, and `paths` extensions. `set`
2254
+ * lowers to a sequence and `any` to its first branch; the `constraints`
2255
+ * extension is parsed-and-ignored.
750
2256
  */
751
- function effectiveOutputName(output, index) {
752
- return output.name ?? `output_${index}`;
753
- }
2257
+ var ArgtypeParser = class {
2258
+ name = "argtype";
2259
+ extensions = ["argtype"];
2260
+ parse(source, _filename) {
2261
+ const { doc, errors: parseErrors, warnings: parseWarnings } = parseArgtype(source);
2262
+ const toLocation = (e) => e.line !== void 0 ? { location: {
2263
+ line: e.line,
2264
+ column: e.column
2265
+ } } : {};
2266
+ const errors = parseErrors.map((e) => ({
2267
+ message: e.message,
2268
+ ...toLocation(e)
2269
+ }));
2270
+ const warnings = parseWarnings.map((e) => ({
2271
+ message: e.message,
2272
+ ...toLocation(e)
2273
+ }));
2274
+ if (!doc) return {
2275
+ expr: emptyExpr$2(),
2276
+ errors,
2277
+ warnings
2278
+ };
2279
+ const lowered = lowerDocument(doc);
2280
+ warnings.push(...lowered.warnings.map((d) => ({
2281
+ message: d.message,
2282
+ ...toLocation(d)
2283
+ })));
2284
+ errors.push(...lowered.errors.map((d) => ({
2285
+ message: d.message,
2286
+ ...toLocation(d)
2287
+ })));
2288
+ return {
2289
+ ...lowered.meta && { meta: lowered.meta },
2290
+ expr: lowered.expr,
2291
+ errors,
2292
+ warnings
2293
+ };
2294
+ }
2295
+ };
754
2296
 
755
2297
  //#endregion
756
2298
  //#region src/frontend/boutiques/destruct-template.ts
@@ -772,20 +2314,26 @@ function destructTemplate(template, lookup) {
772
2314
  destructed.push(x);
773
2315
  continue;
774
2316
  }
775
- let didSplit = false;
2317
+ let best = null;
776
2318
  for (const [alias, replacement] of Object.entries(lookup)) {
2319
+ if (alias.length === 0) continue;
777
2320
  const idx = x.indexOf(alias);
778
- if (idx !== -1) {
779
- const left = x.slice(0, idx);
780
- const right = x.slice(idx + alias.length);
781
- if (right.length > 0) stack.unshift(right);
782
- stack.unshift(replacement);
783
- if (left.length > 0) stack.unshift(left);
784
- didSplit = true;
785
- break;
786
- }
2321
+ if (idx === -1) continue;
2322
+ if (best === null || idx < best.idx || idx === best.idx && alias.length > best.alias.length) best = {
2323
+ idx,
2324
+ alias,
2325
+ replacement
2326
+ };
2327
+ }
2328
+ if (best === null) {
2329
+ destructed.push(x);
2330
+ continue;
787
2331
  }
788
- if (!didSplit) destructed.push(x);
2332
+ const left = x.slice(0, best.idx);
2333
+ const right = x.slice(best.idx + best.alias.length);
2334
+ if (right.length > 0) stack.unshift(right);
2335
+ stack.unshift(best.replacement);
2336
+ if (left.length > 0) stack.unshift(left);
789
2337
  }
790
2338
  return destructed;
791
2339
  }
@@ -1113,14 +2661,8 @@ var BoutiquesParser = class {
1113
2661
  kind: "int",
1114
2662
  attrs: {}
1115
2663
  };
1116
- if (isNumber$1(btInput.minimum)) {
1117
- node.attrs.minValue = Math.floor(btInput.minimum);
1118
- if (btInput["exclusive-minimum"] === true) node.attrs.minValue += 1;
1119
- }
1120
- if (isNumber$1(btInput.maximum)) {
1121
- node.attrs.maxValue = Math.floor(btInput.maximum);
1122
- if (btInput["exclusive-maximum"] === true) node.attrs.maxValue -= 1;
1123
- }
2664
+ if (isNumber$1(btInput.minimum)) node.attrs.minValue = btInput["exclusive-minimum"] === true ? Math.floor(btInput.minimum) + 1 : Math.ceil(btInput.minimum);
2665
+ if (isNumber$1(btInput.maximum)) node.attrs.maxValue = btInput["exclusive-maximum"] === true ? Math.ceil(btInput.maximum) - 1 : Math.floor(btInput.maximum);
1124
2666
  if (meta) node.meta = meta;
1125
2667
  return node;
1126
2668
  }
@@ -1170,7 +2712,10 @@ var BoutiquesParser = class {
1170
2712
  return null;
1171
2713
  }
1172
2714
  const node = this.parseDescriptor(nested);
1173
- if (node && meta) node.meta = meta;
2715
+ if (node && meta) node.meta = {
2716
+ ...node.meta,
2717
+ ...meta
2718
+ };
1174
2719
  return node;
1175
2720
  }
1176
2721
  case InputTypePrimitive.SubCommandUnion: {
@@ -1269,13 +2814,16 @@ var BoutiquesParser = class {
1269
2814
  node = this.wrapWithFlag(node, btInput);
1270
2815
  if (inputType.isOptional) node = this.wrapWithOptional(node);
1271
2816
  if (node !== inner && inner.meta) {
1272
- const { name, ...rest } = inner.meta;
2817
+ const { name, outputs, ...rest } = inner.meta;
1273
2818
  if (Object.keys(rest).length > 0) {
1274
2819
  node.meta = {
1275
2820
  ...node.meta,
1276
2821
  ...rest
1277
2822
  };
1278
- inner.meta = name ? { name } : void 0;
2823
+ const kept = {};
2824
+ if (name) kept.name = name;
2825
+ if (outputs) kept.outputs = outputs;
2826
+ inner.meta = Object.keys(kept).length > 0 ? kept : void 0;
1279
2827
  }
1280
2828
  }
1281
2829
  return node;
@@ -1345,123 +2893,36 @@ var BoutiquesParser = class {
1345
2893
  return {
1346
2894
  expr: {
1347
2895
  kind: "sequence",
1348
- attrs: { nodes: [] }
1349
- },
1350
- errors: this.errors,
1351
- warnings: this.warnings
1352
- };
1353
- }
1354
- const expr = this.parseDescriptor(bt);
1355
- if (expr === null) {
1356
- this.error("Failed to parse command structure");
1357
- return {
1358
- expr: {
1359
- kind: "sequence",
1360
- attrs: { nodes: [] }
1361
- },
1362
- errors: this.errors,
1363
- warnings: this.warnings
1364
- };
1365
- }
1366
- if (!expr.meta?.name && baseMeta?.id) expr.meta = {
1367
- ...expr.meta,
1368
- name: baseMeta.id
1369
- };
1370
- return {
1371
- meta: baseMeta,
1372
- expr,
1373
- errors: this.errors,
1374
- warnings: this.warnings
1375
- };
1376
- }
1377
- };
1378
-
1379
- //#endregion
1380
- //#region src/ir/builders.ts
1381
- function lit(str) {
1382
- return {
1383
- kind: "literal",
1384
- attrs: { str }
1385
- };
1386
- }
1387
- function str(meta) {
1388
- return {
1389
- kind: "str",
1390
- attrs: {},
1391
- meta: normalizeMeta(meta)
1392
- };
1393
- }
1394
- function int(meta) {
1395
- return {
1396
- kind: "int",
1397
- attrs: {},
1398
- meta: normalizeMeta(meta)
1399
- };
1400
- }
1401
- function float(meta) {
1402
- return {
1403
- kind: "float",
1404
- attrs: {},
1405
- meta: normalizeMeta(meta)
1406
- };
1407
- }
1408
- function path(meta) {
1409
- return {
1410
- kind: "path",
1411
- attrs: {},
1412
- meta: normalizeMeta(meta)
1413
- };
1414
- }
1415
- function seq(...nodes) {
1416
- return {
1417
- kind: "sequence",
1418
- attrs: { nodes }
1419
- };
1420
- }
1421
- function seqJoin(join, ...nodes) {
1422
- return {
1423
- kind: "sequence",
1424
- attrs: {
1425
- nodes,
1426
- join
2896
+ attrs: { nodes: [] }
2897
+ },
2898
+ errors: this.errors,
2899
+ warnings: this.warnings
2900
+ };
1427
2901
  }
1428
- };
1429
- }
1430
- function opt(node, meta) {
1431
- return {
1432
- kind: "optional",
1433
- attrs: { node },
1434
- meta: normalizeMeta(meta)
1435
- };
1436
- }
1437
- function rep(node, meta) {
1438
- return {
1439
- kind: "repeat",
1440
- attrs: { node },
1441
- meta: normalizeMeta(meta)
1442
- };
1443
- }
1444
- function repJoin(join, node, meta) {
1445
- return {
1446
- kind: "repeat",
1447
- attrs: {
1448
- node,
1449
- join
1450
- },
1451
- meta: normalizeMeta(meta)
1452
- };
1453
- }
1454
- function alt(...alts) {
1455
- return {
1456
- kind: "alternative",
1457
- attrs: { alts }
1458
- };
1459
- }
1460
- function normalizeMeta(meta) {
1461
- if (meta === void 0) return void 0;
1462
- if (typeof meta === "string") return { name: meta };
1463
- return meta;
1464
- }
2902
+ const expr = this.parseDescriptor(bt);
2903
+ if (expr === null) {
2904
+ this.error("Failed to parse command structure");
2905
+ return {
2906
+ expr: {
2907
+ kind: "sequence",
2908
+ attrs: { nodes: [] }
2909
+ },
2910
+ errors: this.errors,
2911
+ warnings: this.warnings
2912
+ };
2913
+ }
2914
+ if (!expr.meta?.name && baseMeta?.id) expr.meta = {
2915
+ ...expr.meta,
2916
+ name: baseMeta.id
2917
+ };
2918
+ return {
2919
+ meta: baseMeta,
2920
+ expr,
2921
+ errors: this.errors,
2922
+ warnings: this.warnings
2923
+ };
2924
+ }
2925
+ };
1465
2926
 
1466
2927
  //#endregion
1467
2928
  //#region src/backend/string-case.ts
@@ -2211,27 +3672,621 @@ function extractVersion(versionInfo) {
2211
3672
  }
2212
3673
 
2213
3674
  //#endregion
2214
- //#region src/frontend/detect-format.ts
3675
+ //#region src/frontend/detect-format.ts
3676
+ /** The first non-whitespace character of a source ("" if it is all blank). */
3677
+ function firstNonBlankChar(source) {
3678
+ const match = source.match(/\S/);
3679
+ return match ? match[0] : "";
3680
+ }
3681
+ /**
3682
+ * Auto-detect the format of a descriptor source string.
3683
+ *
3684
+ * Every JSON frontend (boutiques, argdump, workbench, mrtrix) is a top-level
3685
+ * object, so the first non-blank character being `{` is the signal for "some
3686
+ * JSON format"; we then inspect its keys to pick which one. Anything else
3687
+ * non-blank is treated as the argtype DSL, whose sources open with a terminal
3688
+ * (`int`), a literal (`"hello"`), a combinator (`seq(...)`), a `name: expr`
3689
+ * definition, or a `---` frontmatter fence - never with `{`.
3690
+ *
3691
+ * Deciding on the leading character (rather than trying `JSON.parse` first) is
3692
+ * what lets standalone argtype snippets like `"hello"` or `42` be recognized:
3693
+ * those are *also* valid JSON scalars, so a parse-first approach would swallow
3694
+ * them and then reject them as "not an object".
3695
+ *
3696
+ * Returns null only when the source is blank, or opens with `{` but matches no
3697
+ * known JSON format (ambiguous, or still being typed).
3698
+ */
3699
+ function detectFormat(source) {
3700
+ const first = firstNonBlankChar(source);
3701
+ if (first === "") return null;
3702
+ if (first !== "{") return "argtype";
3703
+ let parsed;
3704
+ try {
3705
+ parsed = JSON.parse(source);
3706
+ } catch {
3707
+ return null;
3708
+ }
3709
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
3710
+ const obj = parsed;
3711
+ if (typeof obj.$schema === "string" && obj.$schema.includes("argdump")) return "argdump";
3712
+ if (typeof obj.command === "string" && typeof obj.short_description === "string") return "workbench";
3713
+ if (typeof obj.synopsis === "string" && Array.isArray(obj.option_groups) && Array.isArray(obj.arguments)) return "mrtrix";
3714
+ if ("command-line" in obj || Array.isArray(obj.inputs) && "name" in obj) return "boutiques";
3715
+ if (Array.isArray(obj.actions) && "prog" in obj) return "argdump";
3716
+ return null;
3717
+ }
3718
+
3719
+ //#endregion
3720
+ //#region src/backend/scope.ts
3721
+ /** Symbol collision avoidance for code generation. */
3722
+ var Scope = class Scope {
3723
+ reserved;
3724
+ used;
3725
+ parent;
3726
+ constructor(reserved = [], parent) {
3727
+ this.reserved = new Set(reserved);
3728
+ this.used = /* @__PURE__ */ new Set();
3729
+ this.parent = parent;
3730
+ }
3731
+ /** Check if a symbol is already taken (in this scope or any parent). */
3732
+ has(symbol) {
3733
+ return this.reserved.has(symbol) || this.used.has(symbol) || (this.parent?.has(symbol) ?? false);
3734
+ }
3735
+ /**
3736
+ * Add a symbol, appending a numeric suffix to avoid collisions. Returns the
3737
+ * safe name.
3738
+ *
3739
+ * When a `recase` transform is given, a disambiguated candidate is routed back
3740
+ * through it so the suffix is absorbed into the identifier's casing - e.g.
3741
+ * `pascalCase` folds `Config_2` into `Config2` - rather than leaving a
3742
+ * mixed-case `Config_2`. Uniqueness is always checked on the final emitted
3743
+ * form, so two hints that case-collide still get distinct names. Defaults to
3744
+ * identity (the bare `<name>_<n>` suffix) for callers that don't case-normalize.
3745
+ */
3746
+ add(candidate, recase = (s) => s) {
3747
+ if (!this.has(candidate)) {
3748
+ this.used.add(candidate);
3749
+ return candidate;
3750
+ }
3751
+ let suffix = 2;
3752
+ let safe = recase(`${candidate}_${suffix}`);
3753
+ while (this.has(safe)) {
3754
+ suffix++;
3755
+ safe = recase(`${candidate}_${suffix}`);
3756
+ }
3757
+ this.used.add(safe);
3758
+ return safe;
3759
+ }
3760
+ /** Create a child scope that inherits this scope's restrictions. */
3761
+ child(reserved = []) {
3762
+ return new Scope(reserved, this);
3763
+ }
3764
+ };
3765
+
3766
+ //#endregion
3767
+ //#region src/backend/argtype/emit.ts
3768
+ const INDENT = " ";
3769
+ /** Target content width (excluding the `/// ` prefix and indentation) for a
3770
+ * wrapped `///` description line. */
3771
+ const DOC_WIDTH = 80;
3772
+ function pad(level) {
3773
+ return INDENT.repeat(level);
3774
+ }
3775
+ /**
3776
+ * Wrap a description into `///`-ready content lines. Paragraphs (separated by a
3777
+ * blank line) are word-wrapped to `width`; an empty string marks a paragraph
3778
+ * break (the caller emits it as a bare `///`).
3779
+ *
3780
+ * The argtype frontend reflows a `///` block as prose - a single line break
3781
+ * rejoins with a space, a blank line separates paragraphs - so wrapping here is
3782
+ * the inverse: it round-trips paragraph text exactly (whitespace within a
3783
+ * paragraph is normalized to single spaces, matching the frontend's reflow), and
3784
+ * keeps a long description from emitting as one giant physical line.
3785
+ */
3786
+ function wrapDoc(text, width) {
3787
+ const lines = [];
3788
+ for (const paragraph of text.split(/\n{2,}/)) {
3789
+ const words = paragraph.split(/\s+/).filter((w) => w.length > 0);
3790
+ if (words.length === 0) continue;
3791
+ if (lines.length > 0) lines.push("");
3792
+ let cur = "";
3793
+ for (const word of words) if (cur === "") cur = word;
3794
+ else if (cur.length + 1 + word.length <= width) cur += ` ${word}`;
3795
+ else {
3796
+ lines.push(cur);
3797
+ cur = word;
3798
+ }
3799
+ if (cur !== "") lines.push(cur);
3800
+ }
3801
+ return lines;
3802
+ }
3803
+ function num(n) {
3804
+ return String(n);
3805
+ }
3806
+ /** Escape a string for a double-quoted argtype literal (matches the lexer). */
3807
+ function escapeString(s) {
3808
+ return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r");
3809
+ }
3810
+ function quote(s) {
3811
+ return `"${escapeString(s)}"`;
3812
+ }
3813
+ /** A value terminal kind whose `meta.defaultValue` argtype can express. */
3814
+ function isValueTerminal(node) {
3815
+ return node.kind === "int" || node.kind === "float" || node.kind === "str" || node.kind === "path";
3816
+ }
3817
+ /**
3818
+ * Find the single value terminal a wrapper's default should attach to. Descends
3819
+ * through optional/repeat and a sequence with exactly one non-literal child
3820
+ * (the flag-value pattern `seq(lit, value)`). Returns undefined when the value
3821
+ * slot is ambiguous (multiple non-literal children) or absent (a bare literal
3822
+ * flag), in which case the wrapper default has no terminal to sink onto.
3823
+ */
3824
+ function findValueTerminal(node) {
3825
+ switch (node.kind) {
3826
+ case "int":
3827
+ case "float":
3828
+ case "str":
3829
+ case "path": return node;
3830
+ case "optional": return findValueTerminal(node.attrs.node);
3831
+ case "repeat": return findValueTerminal(node.attrs.node);
3832
+ case "sequence": {
3833
+ const nonLiteral = node.attrs.nodes.filter((n) => n.kind !== "literal");
3834
+ return nonLiteral.length === 1 ? findValueTerminal(nonLiteral[0]) : void 0;
3835
+ }
3836
+ default: return;
3837
+ }
3838
+ }
3839
+ /**
3840
+ * Push a wrapper's `meta.defaultValue` down onto its inner value terminal so it
3841
+ * can be emitted as `int = 5` / `.default(...)`. Boutiques (and argparse) hoist
3842
+ * an input's default onto the outermost wrapper (`optional` / `sequence`); the
3843
+ * argtype surface only carries `.default()` on a terminal, so we relocate it.
3844
+ * Boolean defaults are left in place: those are the `opt(literal)` flag-false
3845
+ * convention, which the frontend regenerates for free and which has no terminal.
3846
+ */
3847
+ function sinkDefaults(expr) {
3848
+ const clone = structuredClone(expr);
3849
+ const visit = (node) => {
3850
+ switch (node.kind) {
3851
+ case "optional":
3852
+ visit(node.attrs.node);
3853
+ sinkInto(node);
3854
+ break;
3855
+ case "repeat":
3856
+ visit(node.attrs.node);
3857
+ sinkInto(node);
3858
+ break;
3859
+ case "sequence":
3860
+ for (const c of node.attrs.nodes) visit(c);
3861
+ sinkInto(node);
3862
+ break;
3863
+ case "alternative":
3864
+ for (const c of node.attrs.alts) visit(c);
3865
+ break;
3866
+ default: break;
3867
+ }
3868
+ };
3869
+ visit(clone);
3870
+ return clone;
3871
+ }
3872
+ function sinkInto(wrapper) {
3873
+ const dv = wrapper.meta?.defaultValue;
3874
+ if (dv === void 0 || typeof dv === "boolean") return;
3875
+ const terminal = findValueTerminal(wrapper);
3876
+ if (!terminal || !isValueTerminal(terminal) || terminal.meta?.defaultValue !== void 0) return;
3877
+ terminal.meta = {
3878
+ ...terminal.meta,
3879
+ defaultValue: dv
3880
+ };
3881
+ const meta = { ...wrapper.meta };
3882
+ delete meta.defaultValue;
3883
+ wrapper.meta = Object.keys(meta).length > 0 ? meta : void 0;
3884
+ }
3885
+ /**
3886
+ * Detect the synthetic single-child sequence the frontend wraps a non-sequence
3887
+ * root in. Such a wrapper carries only `name` (matching the child's name) plus
3888
+ * any collected `outputs`; the doc/default live on the inner node. Returns the
3889
+ * inner node and the wrapper's outputs so the caller can emit the inner node as
3890
+ * the definition body (the frontend re-wraps it identically on re-parse).
3891
+ */
3892
+ function syntheticWrap(root) {
3893
+ if (root.kind !== "sequence" || root.attrs.nodes.length !== 1 || root.attrs.join !== void 0) return;
3894
+ const meta = root.meta;
3895
+ if (meta?.doc || meta?.defaultValue !== void 0 || meta?.variantTag !== void 0) return;
3896
+ const child = root.attrs.nodes[0];
3897
+ if (child.kind === "sequence") return void 0;
3898
+ if ((child.meta?.name ?? void 0) !== (meta?.name ?? void 0)) return void 0;
3899
+ return {
3900
+ child,
3901
+ ...meta?.outputs && { outputs: meta.outputs }
3902
+ };
3903
+ }
3904
+ const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
3905
+ /** A name rendered where an identifier is expected: bare when it is a valid
3906
+ * identifier, otherwise a double-quoted form preserving it exactly. Used for
3907
+ * both `label:` prefixes and output-template `{ref}` targets (templates accept a
3908
+ * quoted name, `{"4d_output"}`), so the two always agree and no lossy
3909
+ * sanitization is needed. */
3910
+ function identOrQuoted(name) {
3911
+ return IDENT_RE.test(name) ? name : quote(name);
3912
+ }
3913
+ var ArgtypeEmitter = class {
3914
+ warnings = [];
3915
+ warn(message) {
3916
+ this.warnings.push({ message });
3917
+ }
3918
+ /** Render a name as it appears before a `:` label (bare identifier or quoted
3919
+ * label, e.g. `"1deval":`, `"1D":`). */
3920
+ labelFor(name) {
3921
+ return identOrQuoted(name);
3922
+ }
3923
+ /**
3924
+ * `///` doc lines for a `Documentation`. A title is written as a leading
3925
+ * Markdown H1 (`# Title`); the description follows after a blank `///` line.
3926
+ * Either alone emits just that part.
3927
+ */
3928
+ docLines(doc) {
3929
+ if (!doc) return [];
3930
+ const out = [];
3931
+ if (doc.title) out.push(`/// # ${doc.title}`);
3932
+ if (doc.title && doc.description) out.push("///");
3933
+ if (doc.description) for (const line of wrapDoc(doc.description, DOC_WIDTH)) out.push(line === "" ? "///" : `/// ${line}`);
3934
+ return out;
3935
+ }
3936
+ /**
3937
+ * Whether a `///` block would not round-trip, so the doc must instead be
3938
+ * emitted as `.title()` / `.description()` chaining (which sets the fields
3939
+ * verbatim). Three cases:
3940
+ * - a multi-line title (only its first line would survive `splitDocText`);
3941
+ * - a description with a lone line break (a single `\n`, not a blank-line
3942
+ * paragraph break), which a `///` block reflows to a space - chaining keeps
3943
+ * the hard break intact;
3944
+ * - a title-less description whose first line looks like an H1 heading
3945
+ * (`# ...`), which would be promoted to a spurious title.
3946
+ * A blank-line paragraph break is safe: the frontend reflows a `///` block back
3947
+ * into the same paragraphs, and a description under a title is safe too
3948
+ * (`splitDocText` consumes only the very first line as the title).
3949
+ */
3950
+ docNeedsChain(doc) {
3951
+ if (!doc) return false;
3952
+ if (doc.title?.includes("\n")) return true;
3953
+ const desc = doc.description;
3954
+ if (desc) {
3955
+ if (desc.split(/\n{2,}/).some((para) => para.includes("\n"))) return true;
3956
+ if (!doc.title && (desc.split("\n")[0] ?? "").trim().startsWith("# ")) return true;
3957
+ }
3958
+ return false;
3959
+ }
3960
+ /** `.title(...)` / `.description(...)` chaining for a doc that cannot round-trip
3961
+ * as a `///` block (see `docNeedsChain`). */
3962
+ docChain(doc) {
3963
+ if (!doc) return "";
3964
+ let chain = "";
3965
+ if (doc.title) chain += `.title(${quote(doc.title)})`;
3966
+ if (doc.description) chain += `.description(${quote(doc.description)})`;
3967
+ return chain;
3968
+ }
3969
+ /**
3970
+ * Warn for `Documentation` fields a node's `///` comment cannot carry. `title`
3971
+ * and `description` are emitted (see `docLines`); literature / comment /
3972
+ * authors / urls attached to an inner node have no surface, so surface them
3973
+ * rather than dropping silently.
3974
+ */
3975
+ warnUnrepresentableDoc(doc, where) {
3976
+ if (!doc) return;
3977
+ const lost = [];
3978
+ if (doc.literature?.length) lost.push("literature");
3979
+ if (doc.comment) lost.push("comment");
3980
+ if (doc.authors?.length) lost.push("authors");
3981
+ if (doc.urls?.length) lost.push("urls");
3982
+ if (lost.length > 0) this.warn(`Documentation ${lost.join("/")} on ${where} has no argtype node surface; ignored`);
3983
+ }
3984
+ emit(expr, app) {
3985
+ const raw = sinkDefaults(expr);
3986
+ const lines = [];
3987
+ let exe;
3988
+ let root = raw;
3989
+ if (raw.kind === "sequence" && raw.attrs.nodes[0]?.kind === "literal") {
3990
+ exe = raw.attrs.nodes[0].attrs.str;
3991
+ root = {
3992
+ kind: "sequence",
3993
+ attrs: {
3994
+ ...raw.attrs,
3995
+ nodes: raw.attrs.nodes.slice(1)
3996
+ }
3997
+ };
3998
+ if (raw.meta) root.meta = raw.meta;
3999
+ }
4000
+ const frontmatter = this.emitFrontmatter(app, exe);
4001
+ if (frontmatter) lines.push(frontmatter, "");
4002
+ const synthetic = syntheticWrap(root);
4003
+ const defNode = synthetic ? synthetic.child : root;
4004
+ const wrapperOutputs = synthetic ? synthetic.outputs : void 0;
4005
+ const rootDoc = defNode.meta?.doc ?? app?.doc;
4006
+ const rootChain = this.docNeedsChain(rootDoc);
4007
+ if (!rootChain) lines.push(...this.docLines(rootDoc));
4008
+ this.warnUnrepresentableDoc(defNode.meta?.doc, "the root node");
4009
+ const rootName = this.labelFor(defNode.meta?.name || app?.id || "tool");
4010
+ let body = this.emitNode(defNode, 0);
4011
+ if (rootChain) body += this.docChain(rootDoc);
4012
+ if (wrapperOutputs?.length) body += this.emitOutputs(wrapperOutputs, 0);
4013
+ lines.push(`${rootName}: ${body}`);
4014
+ return lines.join("\n") + "\n";
4015
+ }
4016
+ /**
4017
+ * Emit a node's core expression plus its node-local chains (value
4018
+ * constraints, default, join, count, media types) and any `.output(...)`.
4019
+ * The first line is not indented (the caller places it after a label or pad);
4020
+ * continuation lines are indented relative to `level`.
4021
+ */
4022
+ emitNode(expr, level) {
4023
+ let core;
4024
+ switch (expr.kind) {
4025
+ case "literal":
4026
+ core = quote(expr.attrs.str);
4027
+ break;
4028
+ case "str":
4029
+ core = "str" + this.defaultSuffix(expr, false);
4030
+ break;
4031
+ case "int":
4032
+ case "float": {
4033
+ core = expr.kind;
4034
+ let chained = false;
4035
+ const min = this.finiteNum(expr.attrs.minValue, "min");
4036
+ if (min !== void 0) {
4037
+ core += `.min(${min})`;
4038
+ chained = true;
4039
+ }
4040
+ const max = this.finiteNum(expr.attrs.maxValue, "max");
4041
+ if (max !== void 0) {
4042
+ core += `.max(${max})`;
4043
+ chained = true;
4044
+ }
4045
+ core += this.defaultSuffix(expr, chained);
4046
+ break;
4047
+ }
4048
+ case "path": {
4049
+ core = "path";
4050
+ const media = expr.attrs.mediaTypes ?? [];
4051
+ for (const m of media) core += `.mediaType(${quote(m)})`;
4052
+ if (expr.attrs.mutable) core += ".mutable()";
4053
+ if (expr.attrs.resolveParent) core += ".resolveParent()";
4054
+ const chained = media.length > 0 || !!expr.attrs.mutable || !!expr.attrs.resolveParent;
4055
+ core += this.defaultSuffix(expr, chained);
4056
+ break;
4057
+ }
4058
+ case "sequence":
4059
+ core = this.emitCombinator("seq", expr.attrs.nodes, level, expr.attrs.join);
4060
+ core += this.structuralDefaultSuffix(expr);
4061
+ break;
4062
+ case "optional":
4063
+ core = this.emitOptional(expr, level) + this.structuralDefaultSuffix(expr);
4064
+ break;
4065
+ case "repeat":
4066
+ core = this.emitRepeat(expr, level) + this.structuralDefaultSuffix(expr);
4067
+ break;
4068
+ case "alternative":
4069
+ for (const arm of expr.attrs.alts) {
4070
+ const tag = arm.meta?.variantTag;
4071
+ if (tag !== void 0 && tag !== arm.meta?.name) this.warn(`Union arm discriminator '${tag}' has no argtype surface and will be re-derived from the arm name '${arm.meta?.name ?? "<unnamed>"}' on re-parse`);
4072
+ }
4073
+ core = this.emitCombinator("alt", expr.attrs.alts, level) + this.structuralDefaultSuffix(expr);
4074
+ break;
4075
+ default: core = "";
4076
+ }
4077
+ if (expr.meta?.outputs?.length) core += this.emitOutputs(expr.meta.outputs, level);
4078
+ return core;
4079
+ }
4080
+ /** `String(n)` when `n` is finite; otherwise undefined with a warning, since
4081
+ * `Infinity`/`NaN` have no argtype number literal (emitting them would produce
4082
+ * an identifier that fails to re-parse). */
4083
+ finiteNum(n, where) {
4084
+ if (n === void 0) return void 0;
4085
+ if (!Number.isFinite(n)) {
4086
+ this.warn(`Non-finite number (${String(n)}) on ${where} has no argtype literal; ignored`);
4087
+ return;
4088
+ }
4089
+ return num(n);
4090
+ }
4091
+ /** `= value` on a bare terminal, else `.default(value)` once a chain started. */
4092
+ defaultSuffix(expr, chained) {
4093
+ const dv = expr.meta?.defaultValue;
4094
+ if (dv === void 0 || typeof dv === "boolean") return "";
4095
+ let value;
4096
+ if (typeof dv === "number") {
4097
+ const n = this.finiteNum(dv, "default");
4098
+ if (n === void 0) return "";
4099
+ value = n;
4100
+ } else value = quote(dv);
4101
+ return chained ? `.default(${value})` : ` = ${value}`;
4102
+ }
4103
+ /**
4104
+ * A `.default(value)` chained onto a structural node (e.g. an enum
4105
+ * `alternative`, or a wrapper whose default could not sink onto an inner
4106
+ * terminal). Booleans have no argtype value literal: the only legitimate one
4107
+ * is the `opt(literal)` flag-false convention, which the frontend regenerates,
4108
+ * so it is silently dropped; any other boolean default is warned and dropped.
4109
+ */
4110
+ structuralDefaultSuffix(expr) {
4111
+ const dv = expr.meta?.defaultValue;
4112
+ if (dv === void 0) return "";
4113
+ if (typeof dv === "boolean") {
4114
+ if (!(expr.kind === "optional" && expr.attrs.node.kind === "literal" && dv === false)) this.warn(`Boolean default on a ${expr.kind} cannot be expressed in argtype; ignored`);
4115
+ return "";
4116
+ }
4117
+ if (typeof dv === "number") {
4118
+ const n = this.finiteNum(dv, "default");
4119
+ return n === void 0 ? "" : `.default(${n})`;
4120
+ }
4121
+ return `.default(${quote(dv)})`;
4122
+ }
4123
+ emitOptional(expr, level) {
4124
+ const inner = expr.attrs.node;
4125
+ if (inner.kind === "sequence" && !inner.meta && inner.attrs.join === void 0) return this.emitCombinator("opt", inner.attrs.nodes, level);
4126
+ return this.emitCombinator("opt", [inner], level);
4127
+ }
4128
+ emitRepeat(expr, level) {
4129
+ const node = expr.attrs.node;
4130
+ let core;
4131
+ if (node.kind === "sequence" && !node.meta && node.attrs.join === void 0) core = this.emitCombinator("rep", node.attrs.nodes, level);
4132
+ else core = this.emitCombinator("rep", [node], level);
4133
+ if (expr.attrs.join !== void 0) core += `.join(${expr.attrs.join === "" ? "" : quote(expr.attrs.join)})`;
4134
+ const { countMin, countMax } = expr.attrs;
4135
+ if (countMin !== void 0 && countMin === countMax) core += `.count(${countMin})`;
4136
+ else {
4137
+ if (countMin !== void 0) core += `.countMin(${countMin})`;
4138
+ if (countMax !== void 0) core += `.countMax(${countMax})`;
4139
+ }
4140
+ return core;
4141
+ }
4142
+ emitCombinator(keyword, children, level, join) {
4143
+ let core;
4144
+ if (children.length === 0) core = `${keyword}()`;
4145
+ else {
4146
+ const items = children.map((c) => this.emitDecorated(c, level + 1));
4147
+ const inline = `${keyword}(${items.join(", ")})`;
4148
+ if (!items.some((i) => i.includes("\n")) && inline.length + level * 2 <= 80) core = inline;
4149
+ else core = `${keyword}(\n${items.map((i) => pad(level + 1) + i).join(",\n")},\n${pad(level)})`;
4150
+ }
4151
+ if (join !== void 0) core += `.join(${join === "" ? "" : quote(join)})`;
4152
+ return core;
4153
+ }
4154
+ /**
4155
+ * A list item: leading `///` doc lines, an optional `label:` prefix, then the
4156
+ * node. The first line is unindented (the caller pads it); continuation lines
4157
+ * are indented to `level`.
4158
+ */
4159
+ emitDecorated(expr, level) {
4160
+ const doc = expr.meta?.doc;
4161
+ const useChain = this.docNeedsChain(doc);
4162
+ const parts = useChain ? [] : [...this.docLines(doc)];
4163
+ this.warnUnrepresentableDoc(doc, `node '${expr.meta?.name ?? expr.kind}'`);
4164
+ const label = expr.meta?.name ? `${this.labelFor(expr.meta.name)}: ` : "";
4165
+ let node = this.emitNode(expr, level);
4166
+ if (useChain) node += this.docChain(doc);
4167
+ parts.push(label + node);
4168
+ return parts.join("\n" + pad(level));
4169
+ }
4170
+ emitOutputs(outputs, level) {
4171
+ return `.output(\n${outputs.map((o) => {
4172
+ const useChain = this.docNeedsChain(o.doc);
4173
+ const docs = useChain ? [] : this.docLines(o.doc).map((l) => pad(level + 1) + l);
4174
+ let template = pad(level + 1) + this.emitOutputTemplate(o);
4175
+ if (useChain) template += this.docChain(o.doc);
4176
+ return [...docs, template].join("\n");
4177
+ }).join(",\n")},\n${pad(level)})`;
4178
+ }
4179
+ emitOutputTemplate(output) {
4180
+ if (output.mediaTypes?.length) this.warn(`Output '${output.name ?? "<anon>"}' has media types, which have no argtype surface; ignored`);
4181
+ const label = output.name ? `${this.labelFor(output.name)}: ` : "";
4182
+ let body = "";
4183
+ for (const token of output.tokens) {
4184
+ if (token.kind === "literal") {
4185
+ body += token.value.replace(/[\\{}`]/g, (c) => `\\${c}`);
4186
+ continue;
4187
+ }
4188
+ let ref = identOrQuoted(token.target.name);
4189
+ for (const ext of token.stripExtensions ?? []) ref += `.strip_suffix(${quote(ext)})`;
4190
+ if (token.fallback !== void 0) ref += `.or(${quote(token.fallback)})`;
4191
+ body += `{${ref}}`;
4192
+ }
4193
+ return `${label}\`${body}\``;
4194
+ }
4195
+ /** Quote a frontmatter scalar (double-quoted, backslash-escaped). The
4196
+ * frontmatter parser unescapes symmetrically, so quotes/newlines round-trip. */
4197
+ fmScalar(value) {
4198
+ return quote(value);
4199
+ }
4200
+ emitFrontmatter(app, exe) {
4201
+ const lines = [];
4202
+ if (exe !== void 0) lines.push(`exe: ${this.fmScalar(exe)}`);
4203
+ if (app) {
4204
+ if (app.version) lines.push(`version: ${this.fmScalar(app.version)}`);
4205
+ const authors = app.doc?.authors ?? [];
4206
+ if (authors.length > 0) {
4207
+ lines.push("authors:");
4208
+ for (const a of authors) lines.push(` - ${this.fmScalar(a)}`);
4209
+ }
4210
+ const urls = app.doc?.urls ?? [];
4211
+ if (urls.length > 0) {
4212
+ lines.push("urls:");
4213
+ for (const u of urls) lines.push(` - ${this.fmScalar(u)}`);
4214
+ }
4215
+ const references = app.doc?.literature ?? [];
4216
+ if (references.length > 0) {
4217
+ lines.push("references:");
4218
+ for (const rf of references) lines.push(` - ${this.fmScalar(rf)}`);
4219
+ }
4220
+ if (app.container) {
4221
+ lines.push("container:");
4222
+ lines.push(` image: ${this.fmScalar(app.container.image)}`);
4223
+ if (app.container.type) lines.push(` type: ${this.fmScalar(app.container.type)}`);
4224
+ }
4225
+ if (app.stdout) this.emitStream(lines, "stdout", app.stdout);
4226
+ if (app.stderr) this.emitStream(lines, "stderr", app.stderr);
4227
+ if (app.doc?.comment) this.warn("AppMeta.doc.comment has no argtype surface; ignored");
4228
+ }
4229
+ if (lines.length === 0) return void 0;
4230
+ return [
4231
+ "---",
4232
+ ...lines,
4233
+ "---"
4234
+ ].join("\n");
4235
+ }
4236
+ emitStream(lines, key, stream) {
4237
+ lines.push(`${key}:`);
4238
+ lines.push(` name: ${this.fmScalar(stream.name)}`);
4239
+ if (stream.doc?.description) lines.push(` description: ${this.fmScalar(stream.doc.description)}`);
4240
+ if (stream.doc?.title) this.warn(`${key} stream title has no argtype surface; ignored`);
4241
+ }
4242
+ };
4243
+ /** Emit argtype sugar-DSL source from an IR expression tree and optional `AppMeta`. */
4244
+ function generateArgtype(expr, app) {
4245
+ const emitter = new ArgtypeEmitter();
4246
+ return {
4247
+ source: emitter.emit(expr, app),
4248
+ warnings: emitter.warnings
4249
+ };
4250
+ }
4251
+
4252
+ //#endregion
4253
+ //#region src/backend/argtype/argtype.ts
2215
4254
  /**
2216
- * Auto-detect the format of a JSON descriptor source string.
2217
- * Returns null if the format cannot be determined.
4255
+ * A per-tool file stem, so co-located tools in a catalog build don't clobber one
4256
+ * another's `descriptor.argtype`. Standalone single-tool builds (no scope) keep
4257
+ * the bare name. Mirrors the JSON Schema backend's `schemaStem`.
2218
4258
  */
2219
- function detectFormat(source) {
2220
- let parsed;
2221
- try {
2222
- parsed = JSON.parse(source);
2223
- } catch {
2224
- return null;
2225
- }
2226
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
2227
- const obj = parsed;
2228
- if (typeof obj.$schema === "string" && obj.$schema.includes("argdump")) return "argdump";
2229
- if (typeof obj.command === "string" && typeof obj.short_description === "string") return "workbench";
2230
- if (typeof obj.synopsis === "string" && Array.isArray(obj.option_groups) && Array.isArray(obj.arguments)) return "mrtrix";
2231
- if ("command-line" in obj || Array.isArray(obj.inputs) && "name" in obj) return "boutiques";
2232
- if (Array.isArray(obj.actions) && "prog" in obj) return "argdump";
2233
- return null;
4259
+ function argtypeStem(ctx, scope) {
4260
+ const id = ctx.app?.id;
4261
+ if (!id || !scope) return void 0;
4262
+ return scope.add(snakeCase(id) || "descriptor");
2234
4263
  }
4264
+ /**
4265
+ * Serialization backend: emit argtype sugar-DSL source from the IR + `AppMeta`.
4266
+ *
4267
+ * The dogfooding / round-trip counterpart to the argtype frontend. Like the
4268
+ * Boutiques backend it only needs the IR (`ctx.expr`) and app metadata
4269
+ * (`ctx.app`), never the solved bindings, so it ignores the rest of the context.
4270
+ */
4271
+ var ArgtypeBackend = class {
4272
+ name = "argtype";
4273
+ target = "argtype";
4274
+ /** One scope per package so per-tool file stems stay unique in the suite dir. */
4275
+ newPackageScope() {
4276
+ return new Scope();
4277
+ }
4278
+ emitApp(ctx, scope) {
4279
+ const { source, warnings } = generateArgtype(ctx.expr, ctx.app);
4280
+ const stem = argtypeStem(ctx, scope);
4281
+ const filename = stem ? `${stem}.argtype` : "descriptor.argtype";
4282
+ return {
4283
+ meta: ctx.app,
4284
+ files: new Map([[filename, source]]),
4285
+ errors: [],
4286
+ warnings
4287
+ };
4288
+ }
4289
+ };
2235
4290
 
2236
4291
  //#endregion
2237
4292
  //#region src/backend/find-doc.ts
@@ -2312,6 +4367,7 @@ function resolveFieldBinding(node, ctx, structType, outermost) {
2312
4367
  * sequence is the actual struct owner (e.g. `seq(lit("--flag"), seq(field1, field2))`).
2313
4368
  */
2314
4369
  function findStructNode(node, ctx, structType) {
4370
+ if (node.kind === "sequence" && Object.keys(structType.fields).length === 0) return node;
2315
4371
  switch (node.kind) {
2316
4372
  case "sequence":
2317
4373
  for (const child of node.attrs.nodes) {
@@ -2517,100 +4573,6 @@ function formatType(type, indent = 0) {
2517
4573
  }
2518
4574
  }
2519
4575
 
2520
- //#endregion
2521
- //#region src/backend/resolve-output-tokens.ts
2522
- /**
2523
- * Compact consecutive literal tokens. Backends that emit string concatenation
2524
- * benefit from a shorter token stream; backends that template each token
2525
- * individually can ignore this and use `output.tokens` directly.
2526
- */
2527
- function compactTokens(tokens) {
2528
- const out = [];
2529
- for (const tok of tokens) {
2530
- const last = out[out.length - 1];
2531
- if (tok.kind === "literal" && last && last.kind === "literal") out[out.length - 1] = {
2532
- kind: "literal",
2533
- value: last.value + tok.value
2534
- };
2535
- else out.push(tok);
2536
- }
2537
- return out;
2538
- }
2539
- function planOutput(scopeGate, output, bindings) {
2540
- return {
2541
- name: output.name,
2542
- gate: outputGate(scopeGate, output, bindings),
2543
- tokens: compactTokens(output.tokens),
2544
- resolved: output
2545
- };
2546
- }
2547
- /**
2548
- * Does the output have any conditional wrapper? Equivalent to "is at least one
2549
- * atom a `present` or `variant`?". `iter` alone means the output emits a list
2550
- * and is not conditionally absent.
2551
- */
2552
- function isGated(plan) {
2553
- return plan.gate.some((a) => a.kind === "present" || a.kind === "variant");
2554
- }
2555
- /** Does the output iterate (emit zero-or-more values)? */
2556
- function isIterated(plan) {
2557
- return plan.gate.some((a) => a.kind === "iter");
2558
- }
2559
- /**
2560
- * Convenience for backends emitting all outputs of a scope at once. The caller
2561
- * provides the scope's gate (typically `bindings.get(scope.scope)?.gate ?? []`).
2562
- */
2563
- function planScope(scope, scopeGate, bindings) {
2564
- return scope.outputs.map((output) => planOutput(scopeGate, output, bindings));
2565
- }
2566
-
2567
- //#endregion
2568
- //#region src/backend/scope.ts
2569
- /** Symbol collision avoidance for code generation. */
2570
- var Scope = class Scope {
2571
- reserved;
2572
- used;
2573
- parent;
2574
- constructor(reserved = [], parent) {
2575
- this.reserved = new Set(reserved);
2576
- this.used = /* @__PURE__ */ new Set();
2577
- this.parent = parent;
2578
- }
2579
- /** Check if a symbol is already taken (in this scope or any parent). */
2580
- has(symbol) {
2581
- return this.reserved.has(symbol) || this.used.has(symbol) || (this.parent?.has(symbol) ?? false);
2582
- }
2583
- /**
2584
- * Add a symbol, appending a numeric suffix to avoid collisions. Returns the
2585
- * safe name.
2586
- *
2587
- * When a `recase` transform is given, a disambiguated candidate is routed back
2588
- * through it so the suffix is absorbed into the identifier's casing - e.g.
2589
- * `pascalCase` folds `Config_2` into `Config2` - rather than leaving a
2590
- * mixed-case `Config_2`. Uniqueness is always checked on the final emitted
2591
- * form, so two hints that case-collide still get distinct names. Defaults to
2592
- * identity (the bare `<name>_<n>` suffix) for callers that don't case-normalize.
2593
- */
2594
- add(candidate, recase = (s) => s) {
2595
- if (!this.has(candidate)) {
2596
- this.used.add(candidate);
2597
- return candidate;
2598
- }
2599
- let suffix = 2;
2600
- let safe = recase(`${candidate}_${suffix}`);
2601
- while (this.has(safe)) {
2602
- suffix++;
2603
- safe = recase(`${candidate}_${suffix}`);
2604
- }
2605
- this.used.add(safe);
2606
- return safe;
2607
- }
2608
- /** Create a child scope that inherits this scope's restrictions. */
2609
- child(reserved = []) {
2610
- return new Scope(reserved, this);
2611
- }
2612
- };
2613
-
2614
4576
  //#endregion
2615
4577
  //#region src/backend/boutiques/boutiques.ts
2616
4578
  var BoutiquesEmitter = class {
@@ -2826,14 +4788,12 @@ var BoutiquesEmitter = class {
2826
4788
  if (flagStr) peeled.flag = flagStr;
2827
4789
  }
2828
4790
  const input = this.buildInput(binding, id, fieldType, valueKeyStr, peeled, fieldInfo, wrapperNode);
2829
- if (peeled.flag) if (fieldType.kind === "bool" || fieldType.kind === "optional" && this.isBool(fieldType)) commandParts.push(valueKeyStr);
2830
- else commandParts.push(valueKeyStr);
2831
- else commandParts.push(valueKeyStr);
4791
+ commandParts.push(valueKeyStr);
2832
4792
  inputs.push(input);
2833
4793
  }
2834
4794
  bt["command-line"] = commandParts.join(" ");
2835
4795
  bt.inputs = inputs;
2836
- this.emitOutputFiles(bt, expr, valueKeyByBinding, idScope);
4796
+ this.emitOutputFiles(bt, structNode, valueKeyByBinding, idScope);
2837
4797
  }
2838
4798
  emitOutputFiles(bt, scopeNode, valueKeys, idScope) {
2839
4799
  const scopeBinding = this.ctx.resolve(scopeNode);
@@ -2926,6 +4886,7 @@ var BoutiquesEmitter = class {
2926
4886
  }
2927
4887
  finalizeInput(input) {
2928
4888
  if (input.name === void 0) input.name = input.id;
4889
+ this.finalizeSubDescriptors(input);
2929
4890
  if (input.type === "String" && typeof input["default-value"] === "boolean" && input["value-choices"] === void 0) input.type = "Flag";
2930
4891
  if (input.type === "Flag") {
2931
4892
  delete input["default-value"];
@@ -2952,6 +4913,15 @@ var BoutiquesEmitter = class {
2952
4913
  if (Array.isArray(choices) && dv !== void 0 && !choices.some((c) => c === dv)) delete input["default-value"];
2953
4914
  this.mergeDefaultIntoDescription(input);
2954
4915
  }
4916
+ finalizeSubDescriptors(input) {
4917
+ const type = input.type;
4918
+ if (Array.isArray(type)) type.forEach((sub, i) => this.ensureSubDescriptorId(sub, `${input.id}_${i + 1}`));
4919
+ else if (typeof type === "object" && type !== null) this.ensureSubDescriptorId(type, input.id);
4920
+ }
4921
+ ensureSubDescriptorId(sub, fallback) {
4922
+ if (sub.id === void 0) sub.id = this.sanitizeId(fallback);
4923
+ if (sub.name === void 0) sub.name = sub.id;
4924
+ }
2955
4925
  peelNode(node, type) {
2956
4926
  const result = {
2957
4927
  isOptional: false,
@@ -2975,6 +4945,8 @@ var BoutiquesEmitter = class {
2975
4945
  this.peelNodeInner(node.attrs.node, type.kind === "list" ? type.item : type, result);
2976
4946
  break;
2977
4947
  case "sequence": {
4948
+ const structType = this.unwrapType(type);
4949
+ if (structType.kind === "struct" && findStructNode(node, this.ctx, structType) === node) break;
2978
4950
  const nodes = node.attrs.nodes;
2979
4951
  if (nodes.length === 2 && nodes[0].kind === "literal") {
2980
4952
  const flagLit = nodes[0].attrs.str;
@@ -3063,8 +5035,7 @@ var BoutiquesEmitter = class {
3063
5035
  type: "String",
3064
5036
  valueChoices: type.variants.map((v) => v.type.kind === "literal" ? v.type.value : "")
3065
5037
  };
3066
- if (type.variants.every((v) => v.type.kind === "struct")) return { type: this.buildSubCommandUnion(type, node) };
3067
- return { type: this.buildMixedUnionAsSubCommands(type, node) };
5038
+ return { type: this.buildUnionSubCommands(type, node) };
3068
5039
  }
3069
5040
  buildSubCommand(type, node) {
3070
5041
  const bt = {};
@@ -3075,22 +5046,7 @@ var BoutiquesEmitter = class {
3075
5046
  }
3076
5047
  return bt;
3077
5048
  }
3078
- buildSubCommandUnion(type, node) {
3079
- const alts = node.kind === "alternative" ? node.attrs.alts : [node];
3080
- return type.variants.map((variant, i) => {
3081
- const altNode = alts[i] ?? node;
3082
- if (variant.type.kind === "struct") {
3083
- const bt = this.buildSubCommand(variant.type, altNode);
3084
- if (variant.name) {
3085
- bt.name = variant.name;
3086
- bt.id = this.sanitizeId(variant.name);
3087
- }
3088
- return bt;
3089
- }
3090
- return this.wrapAsDescriptor(variant, altNode);
3091
- });
3092
- }
3093
- buildMixedUnionAsSubCommands(type, node) {
5049
+ buildUnionSubCommands(type, node) {
3094
5050
  const alts = node.kind === "alternative" ? node.attrs.alts : [node];
3095
5051
  return type.variants.map((variant, i) => {
3096
5052
  const altNode = alts[i] ?? node;
@@ -3189,7 +5145,8 @@ var BoutiquesEmitter = class {
3189
5145
  inputs: []
3190
5146
  },
3191
5147
  list: true,
3192
- minListEntries: countMin
5148
+ minListEntries: countMin,
5149
+ ...countMin === 0 && { optional: true }
3193
5150
  };
3194
5151
  }
3195
5152
  };
@@ -3395,9 +5352,65 @@ function resolveTypeName(namedTypes) {
3395
5352
  };
3396
5353
  }
3397
5354
 
5355
+ //#endregion
5356
+ //#region src/backend/field-defaults.ts
5357
+ /**
5358
+ * Build the field-name -> rendered-default map for a struct root (else empty).
5359
+ * Includes only non-optional defaulted fields (optional fields are
5360
+ * presence-guarded; their default comes from the factory's kwarg signature).
5361
+ *
5362
+ * `rootType` defaults to the resolved root type; callers that already have it
5363
+ * (the arg-builders) pass it to avoid re-resolving.
5364
+ */
5365
+ function collectDefaults(ctx, renderLiteral, rootType = ctx.resolve(ctx.expr)?.type) {
5366
+ const out = /* @__PURE__ */ new Map();
5367
+ if (rootType?.kind !== "struct") return out;
5368
+ for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
5369
+ if (fi.defaultValue === void 0) continue;
5370
+ if (rootType.fields[name]?.kind === "optional") continue;
5371
+ out.set(name, renderLiteral(fi.defaultValue));
5372
+ }
5373
+ return out;
5374
+ }
5375
+ /** The rendered default for a binding iff it is a root-level defaulted field. */
5376
+ function rootFieldDefault(binding, defaults) {
5377
+ if (!binding) return void 0;
5378
+ const a = binding.access;
5379
+ if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
5380
+ }
5381
+
3398
5382
  //#endregion
3399
5383
  //#region src/backend/union-variants.ts
3400
5384
  /**
5385
+ * Whether a union is "mixed": it has at least one non-struct (bare-literal) arm
5386
+ * alongside its struct variants (e.g. ants `n4_correction = Literal[0] | N4On`).
5387
+ *
5388
+ * The `@type` discriminator only exists on the struct arms, so indexing
5389
+ * `value["@type"]` on the union value is unsound (a type error, and a runtime
5390
+ * KeyError if the literal arm is ever hit) until the value is narrowed to a
5391
+ * struct at runtime. Backends that dispatch on `@type` must first emit a shape
5392
+ * guard (Python `isinstance(value, dict)`, TS `typeof value === "object"`) when
5393
+ * this is true; a pure-struct union (every arm a struct) needs no guard.
5394
+ */
5395
+ function unionIsMixed(unionType) {
5396
+ return unionType.variants.some((v) => v.type.kind !== "struct");
5397
+ }
5398
+ /**
5399
+ * The union type bound by a `variant` gate atom, if the atom's binding resolves
5400
+ * to a union - used by the outputs emitters to decide whether the variant gate
5401
+ * needs a mixed-union shape guard before its `@type` check. Returns `undefined`
5402
+ * for a well-formed non-union (defensive; a variant atom should always name a
5403
+ * union binding).
5404
+ */
5405
+ function variantAtomUnion(atom, bindings) {
5406
+ return resolveUnion(bindings.get(atom.binding)?.type);
5407
+ }
5408
+ function resolveUnion(type) {
5409
+ if (!type) return void 0;
5410
+ if (type.kind === "optional") return resolveUnion(type.inner);
5411
+ if (type.kind === "union") return type;
5412
+ }
5413
+ /**
3401
5414
  * The struct variants of a union, each with its index into `variants`.
3402
5415
  *
3403
5416
  * A discriminated union dispatches on a unique `@type`, so two struct variants
@@ -3535,10 +5548,6 @@ function pathArg$1(node, expr) {
3535
5548
  * carrying a Boutiques default. Restricted to single-segment (root) field access
3536
5549
  * so a nested field can never accidentally pick up a same-named root default.
3537
5550
  */
3538
- function rootFieldDefault$3(binding, defaults) {
3539
- const a = binding.access;
3540
- if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
3541
- }
3542
5551
  /**
3543
5552
  * Render a binding's access for an UNCONDITIONAL value read (terminal, repeat
3544
5553
  * loop, alternative dispatch): substitutes the field's default via
@@ -3549,7 +5558,7 @@ function rootFieldDefault$3(binding, defaults) {
3549
5558
  * via `.get()`).
3550
5559
  */
3551
5560
  function readAccess$1(binding, arg) {
3552
- const def = rootFieldDefault$3(binding, arg.defaults);
5561
+ const def = rootFieldDefault(binding, arg.defaults);
3553
5562
  return accessOf$1(binding, arg, def !== void 0 ? { finalDefault: def } : {});
3554
5563
  }
3555
5564
  /**
@@ -3569,21 +5578,6 @@ function accessOf$1(binding, arg, opts = {}) {
3569
5578
  subst: arg.valueSubst
3570
5579
  });
3571
5580
  }
3572
- /**
3573
- * Build the field-name -> rendered-default map for a struct root (else empty).
3574
- * Includes only non-optional defaulted fields (optional fields are
3575
- * presence-guarded; their default comes from the factory's kwarg signature).
3576
- */
3577
- function collectDefaults$3(ctx, rootType) {
3578
- const out = /* @__PURE__ */ new Map();
3579
- if (rootType?.kind !== "struct") return out;
3580
- for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
3581
- if (fi.defaultValue === void 0) continue;
3582
- if (rootType.fields[name]?.kind === "optional") continue;
3583
- out.set(name, renderPyLiteral(fi.defaultValue));
3584
- }
3585
- return out;
3586
- }
3587
5581
  let loopVarCounter$1 = 0;
3588
5582
  let optVarCounter = 0;
3589
5583
  /**
@@ -3599,7 +5593,7 @@ function buildArgs$1(rootExpr, ctx, rootType) {
3599
5593
  joinDepth: 0,
3600
5594
  loopVars: /* @__PURE__ */ new Map(),
3601
5595
  valueSubst: /* @__PURE__ */ new Map(),
3602
- defaults: collectDefaults$3(ctx, rootType)
5596
+ defaults: collectDefaults(ctx, renderPyLiteral, rootType)
3603
5597
  });
3604
5598
  }
3605
5599
  function walk$2(node, ctx, arg) {
@@ -3759,8 +5753,8 @@ function walkAlternative$1(node, ctx, arg) {
3759
5753
  */
3760
5754
  function emitDocstring(cb, text) {
3761
5755
  if (!text) return;
3762
- const lines = text.split("\n");
3763
- if (lines.length === 1 && !lines[0].includes("\"")) {
5756
+ const lines = text.replace(/"""/g, "\\\"\\\"\\\"").split("\n");
5757
+ if (lines.length === 1 && !lines[0].includes("\"") && !lines[0].endsWith("\\")) {
3764
5758
  cb.line(`"""${lines[0]}"""`);
3765
5759
  return;
3766
5760
  }
@@ -3768,20 +5762,18 @@ function emitDocstring(cb, text) {
3768
5762
  for (const line of lines) cb.line(line);
3769
5763
  cb.line(`"""`);
3770
5764
  }
3771
- function emitImports$1(cb, emitOutputs) {
3772
- cb.line("import dataclasses");
5765
+ function emitImports$1(cb) {
3773
5766
  cb.line("import pathlib");
3774
5767
  cb.line("import typing");
3775
5768
  cb.blank();
3776
- const fromStyxdefs = [
5769
+ cb.line(`from styxdefs import ${[
3777
5770
  "Execution",
3778
5771
  "InputPathType",
3779
5772
  "Metadata",
3780
5773
  "Runner",
3781
- "StyxValidationError"
3782
- ];
3783
- if (emitOutputs) fromStyxdefs.push("OutputPathType");
3784
- cb.line(`from styxdefs import ${fromStyxdefs.join(", ")}, get_global_runner`);
5774
+ "StyxValidationError",
5775
+ "OutputPathType"
5776
+ ].join(", ")}, get_global_runner`);
3785
5777
  }
3786
5778
  function emitMetadata$1(ctx, metaConst, cb) {
3787
5779
  const id = ctx.app?.id ?? "unknown";
@@ -4283,9 +6275,10 @@ function renderInputTrait(p) {
4283
6275
  case "str": return call("traits.Str", [...hasDef ? [renderPyLiteral(def), "usedefault=True"] : [], ...tail]);
4284
6276
  case "enum": {
4285
6277
  const choices = p.choices ?? [];
6278
+ const defChoice = hasDef && def !== void 0 && typeof def !== "boolean" && choices.includes(def) ? def : void 0;
4286
6279
  return call("traits.Enum", [
4287
- ...(hasDef ? [def, ...choices.filter((c) => c !== def)] : choices).map((c) => renderPyLiteral(c)),
4288
- ...hasDef ? ["usedefault=True"] : [],
6280
+ ...(defChoice !== void 0 ? [defChoice, ...choices.filter((c) => c !== defChoice)] : choices).map((c) => renderPyLiteral(c)),
6281
+ ...defChoice !== void 0 ? ["usedefault=True"] : [],
4289
6282
  ...tail
4290
6283
  ]);
4291
6284
  }
@@ -4765,7 +6758,7 @@ function emitValue$1(e, type, node, wireKey, valueExpr, expected) {
4765
6758
  const itemNode = findRepeatNode(node)?.attrs.node;
4766
6759
  const elem = e.scope.add("e");
4767
6760
  e.cb.line(`for ${elem} in ${valueExpr}:`);
4768
- e.cb.indent(() => emitValue$1(e, type.item, itemNode, wireKey, elem, expected));
6761
+ e.cb.indent(() => emitValue$1(e, type.item, itemNode, wireKey, elem, expectedType$1(e, type.item)));
4769
6762
  return;
4770
6763
  }
4771
6764
  case "struct":
@@ -5052,10 +7045,9 @@ function streamFieldIds$1(ctx) {
5052
7045
  if (ctx.app?.stderr) res.stderr = fields[idx++].id;
5053
7046
  return res;
5054
7047
  }
5055
- /** Emit `@dataclasses.dataclass\nclass <outputsType>:` declaration. */
7048
+ /** Emit `class <outputsType>(typing.NamedTuple):` declaration. */
5056
7049
  function emitOutputsClass(ctx, outputsType, cb) {
5057
- cb.line("@dataclasses.dataclass");
5058
- cb.line(`class ${outputsType}:`);
7050
+ cb.line(`class ${outputsType}(typing.NamedTuple):`);
5059
7051
  cb.indent(() => {
5060
7052
  emitDocstring(cb, "Output paths produced by the tool.");
5061
7053
  const fields = collectOutputFields(ctx, pyId);
@@ -5074,26 +7066,6 @@ function emitOutputsClass(ctx, outputsType, cb) {
5074
7066
  }
5075
7067
  });
5076
7068
  }
5077
- /** The rendered default for a binding iff it is a root-level defaulted field. */
5078
- function rootFieldDefault$2(binding, defaults) {
5079
- if (!binding) return void 0;
5080
- const a = binding.access;
5081
- if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
5082
- }
5083
- /** Build the field-name -> rendered-default map for the struct root (else empty).
5084
- * Includes only non-optional defaulted fields (optional fields are
5085
- * presence-guarded; their default comes from the factory's kwarg signature). */
5086
- function collectDefaults$2(ctx) {
5087
- const out = /* @__PURE__ */ new Map();
5088
- const rootType = ctx.resolve(ctx.expr)?.type;
5089
- if (rootType?.kind !== "struct") return out;
5090
- for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
5091
- if (fi.defaultValue === void 0) continue;
5092
- if (rootType.fields[name]?.kind === "optional") continue;
5093
- out.set(name, renderPyLiteral(fi.defaultValue));
5094
- }
5095
- return out;
5096
- }
5097
7069
  let loopCounter$1 = 0;
5098
7070
  function renderWrapperOpen$1(atom, ec) {
5099
7071
  if (atom.kind === "iter") {
@@ -5104,7 +7076,13 @@ function renderWrapperOpen$1(atom, ec) {
5104
7076
  loopVar: v
5105
7077
  };
5106
7078
  }
5107
- if (atom.kind === "variant") return { open: `if ${bindingAccess$1(atom.binding, ec)}["@type"] == ${pyStr(atom.variant)}:` };
7079
+ if (atom.kind === "variant") {
7080
+ const access = bindingAccess$1(atom.binding, ec);
7081
+ const check = `${access}["@type"] == ${pyStr(atom.variant)}`;
7082
+ const union = variantAtomUnion(atom, ec.ctx.bindings);
7083
+ if (union && unionIsMixed(union)) return { open: `if isinstance(${access}, dict) and ${check}:` };
7084
+ return { open: `if ${check}:` };
7085
+ }
5108
7086
  const binding = ec.ctx.bindings.get(atom.binding);
5109
7087
  if (binding?.type.kind === "optional") {
5110
7088
  const subscriptAccess = bindingAccess$1(atom.binding, ec);
@@ -5160,7 +7138,7 @@ function renderToken$1(tok, ec) {
5160
7138
  return renderRefValue$1(tok, ec);
5161
7139
  }
5162
7140
  function renderRefValue$1(tok, ec) {
5163
- const def = rootFieldDefault$2(ec.ctx.bindings.get(tok.binding), ec.defaults);
7141
+ const def = rootFieldDefault(ec.ctx.bindings.get(tok.binding), ec.defaults);
5164
7142
  let expr = def !== void 0 && !ec.iter.has(tok.binding) ? bindingAccess$1(tok.binding, ec, false, def) : bindingAccess$1(tok.binding, ec);
5165
7143
  if (tok.fallback !== void 0) expr = `(${expr} if ${expr} is not None else ${pyStr(tok.fallback)})`;
5166
7144
  if (tok.stripExtensions && tok.stripExtensions.length > 0) {
@@ -5223,7 +7201,7 @@ function emitBuildOutputs$1(ctx, paramsType, outputsType, funcName, cb) {
5223
7201
  ctx,
5224
7202
  iter: /* @__PURE__ */ new Map(),
5225
7203
  subst: /* @__PURE__ */ new Map(),
5226
- defaults: collectDefaults$2(ctx)
7204
+ defaults: collectDefaults(ctx, renderPyLiteral)
5227
7205
  };
5228
7206
  const fields = collectOutputFields(ctx, pyId);
5229
7207
  const localVarOf = /* @__PURE__ */ new Map();
@@ -5260,11 +7238,16 @@ function emitBuildOutputs$1(ctx, paramsType, outputsType, funcName, cb) {
5260
7238
  }
5261
7239
  });
5262
7240
  }
5263
- /** Sanitize an output name to a valid Python identifier. */
7241
+ /**
7242
+ * Sanitize an output name to a valid Python identifier. Uses a *letter*-leading
7243
+ * prefix (`v_`) for digit-leading / empty names, never a leading underscore:
7244
+ * the Outputs type is a `typing.NamedTuple`, which raises `ValueError` at import
7245
+ * time for a field whose name starts with `_`. Matches styx1 and `pyScrubIdent`.
7246
+ * (A trailing underscore for keywords is fine - only leading underscores fail.)
7247
+ */
5264
7248
  function pyId(name) {
5265
7249
  let s = name.replace(/[^a-zA-Z0-9_]/g, "_");
5266
- if (/^\d/.test(s)) s = "_" + s;
5267
- if (s === "") s = "_";
7250
+ if (/^\d/.test(s) || s === "") s = "v_" + s;
5268
7251
  if (PY_KEYWORDS.has(s)) s = s + "_";
5269
7252
  return s;
5270
7253
  }
@@ -5450,13 +7433,22 @@ function buildEmitModel(ctx, scope = new Scope(PY_RESERVED)) {
5450
7433
  };
5451
7434
  }
5452
7435
  function generatePython(ctx, packageScope) {
7436
+ return generatePythonModule(ctx, packageScope).code;
7437
+ }
7438
+ /**
7439
+ * Emit the module and, alongside it, the dispatch entrypoint carrying the
7440
+ * *scope-registered* execute-function name. Computing the entrypoint here (not
7441
+ * via the scope-blind `appEntrypoint`) keeps the suite dispatcher in sync with
7442
+ * the actual emitted symbol when a shared package scope suffix-bumps a collision.
7443
+ */
7444
+ function generatePythonModule(ctx, packageScope) {
5453
7445
  const cb = new CodeBuilder(" ");
5454
7446
  const scope = packageScope ?? new Scope(PY_RESERVED);
5455
7447
  const { names, rootType, rootIsStruct, namedTypes, typeDecls, rootTypeTag, paramsType, sigEntries, nestedFactories } = buildEmitModel(ctx, scope);
5456
7448
  cb.comment("This file was auto generated by Styx.", "# ");
5457
7449
  cb.comment("Do not edit this file directly.", "# ");
5458
7450
  cb.blank();
5459
- emitImports$1(cb, true);
7451
+ emitImports$1(cb);
5460
7452
  cb.blank();
5461
7453
  emitMetadata$1(ctx, names.metadata, cb);
5462
7454
  cb.blank();
@@ -5481,7 +7473,8 @@ function generatePython(ctx, packageScope) {
5481
7473
  cb.blank();
5482
7474
  emitBuildOutputs$1(ctx, paramsType, names.outputs, names.outputsFn, cb);
5483
7475
  cb.blank();
5484
- emitWrapperFunction$1(ctx, paramsType, rootIsStruct ? names.execute : names.wrapper, names.metadata, names.cargs, names.outputsFn, names.outputs, names.validate, streamFieldIds$1(ctx), cb);
7476
+ const executeName = rootIsStruct ? names.execute : names.wrapper;
7477
+ emitWrapperFunction$1(ctx, paramsType, executeName, names.metadata, names.cargs, names.outputsFn, names.outputs, names.validate, streamFieldIds$1(ctx), cb);
5485
7478
  cb.blank();
5486
7479
  if (rootIsStruct) {
5487
7480
  emitKwargWrapper$1(ctx, sigEntries, names.wrapper, names.paramsFn, names.execute, names.outputs, cb);
@@ -5489,10 +7482,10 @@ function generatePython(ctx, packageScope) {
5489
7482
  }
5490
7483
  const publicSymbols = [
5491
7484
  names.params,
5492
- ...[names.outputs],
7485
+ names.outputs,
5493
7486
  names.metadata,
5494
7487
  names.cargs,
5495
- ...[names.outputsFn],
7488
+ names.outputsFn,
5496
7489
  ...rootIsStruct ? [names.paramsFn, names.execute] : [],
5497
7490
  ...nestedFactories.map((nf) => nf.funcName),
5498
7491
  names.validate,
@@ -5503,7 +7496,16 @@ function generatePython(ctx, packageScope) {
5503
7496
  for (const sym of publicSymbols) cb.line(`"${sym}",`);
5504
7497
  });
5505
7498
  cb.line("]");
5506
- return cb.toString();
7499
+ const appId = ctx.app?.id;
7500
+ const pkg = ctx.package?.name;
7501
+ const entrypoint = appId && pkg ? {
7502
+ type: `${pkg}/${appId}`,
7503
+ executeFn: executeName
7504
+ } : void 0;
7505
+ return {
7506
+ code: cb.toString(),
7507
+ entrypoint
7508
+ };
5507
7509
  }
5508
7510
  /**
5509
7511
  * Module name (file stem) for an app: snake_case of app.id, fallback `output`.
@@ -5515,22 +7517,6 @@ function appModuleName$1(meta) {
5515
7517
  return pyScrubIdent(snakeCase(meta.id), PY_RESERVED);
5516
7518
  }
5517
7519
  /**
5518
- * The dispatch entrypoint for one app: its root `@type` (`<package>/<app>`) and
5519
- * the dict-style execute function name. Returns undefined when the id or package
5520
- * is unknown (no stable `@type`), so the app is left out of the suite dispatcher.
5521
- */
5522
- function appEntrypoint$1(ctx) {
5523
- const appId = ctx.app?.id;
5524
- const pkg = ctx.package?.name;
5525
- if (!appId || !pkg) return void 0;
5526
- const publicNames = computePublicNames$1(appId);
5527
- const executeFn = pyScrubIdent(ctx.resolve(ctx.expr)?.type.kind === "struct" ? publicNames.execute : publicNames.wrapper, PY_RESERVED);
5528
- return {
5529
- type: `${pkg}/${appId}`,
5530
- executeFn
5531
- };
5532
- }
5533
- /**
5534
7520
  * Generate the suite-level `__init__.py` re-export for a package containing
5535
7521
  * multiple tool modules. Each tool module's public symbols are surfaced via
5536
7522
  * `from .bet import *` (each tool file defines `__all__`). When apps carry a
@@ -5567,10 +7553,11 @@ function emitPackageDispatch$1(cb, dispatch) {
5567
7553
  for (const e of dispatch) cb.line(`${JSON.stringify(e.type)}: ${e.executeFn},`);
5568
7554
  });
5569
7555
  cb.line("}");
5570
- cb.line("_fn = _dispatch.get(params[\"@type\"])");
7556
+ cb.line("_type = params.get(\"@type\")");
7557
+ cb.line("_fn = _dispatch.get(_type) if _type is not None else None");
5571
7558
  cb.line("if _fn is None:");
5572
7559
  cb.indent(() => {
5573
- cb.line(`raise ValueError(f"No tool registered for @type {params['@type']!r}")`);
7560
+ cb.line(`raise ValueError(f"No tool registered for @type {_type!r}")`);
5574
7561
  });
5575
7562
  cb.line("return _fn(params, runner)");
5576
7563
  });
@@ -5579,11 +7566,11 @@ var PythonBackend = class {
5579
7566
  name = "python";
5580
7567
  target = "python";
5581
7568
  emitApp(ctx, scope) {
5582
- const code = generatePython(ctx, scope);
7569
+ const { code, entrypoint } = generatePythonModule(ctx, scope);
5583
7570
  const fileName = `${appModuleName$1(ctx.app)}.py`;
5584
7571
  return {
5585
7572
  meta: ctx.app,
5586
- entrypoint: appEntrypoint$1(ctx),
7573
+ entrypoint,
5587
7574
  files: new Map([[fileName, code]]),
5588
7575
  errors: [],
5589
7576
  warnings: []
@@ -6577,6 +8564,10 @@ var JsonSchemaBackend = class {
6577
8564
 
6578
8565
  //#endregion
6579
8566
  //#region src/backend/typescript/typemap.ts
8567
+ const TS_IDENT_RE$1 = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
8568
+ function objKey(key) {
8569
+ return TS_IDENT_RE$1.test(key) ? key : JSON.stringify(key);
8570
+ }
6580
8571
  function mapType(type, resolve) {
6581
8572
  switch (type.kind) {
6582
8573
  case "scalar": return {
@@ -6596,7 +8587,7 @@ function mapType(type, resolve) {
6596
8587
  case "struct": {
6597
8588
  const name = resolve(type);
6598
8589
  if (name) return name;
6599
- return `{ ${Object.entries(type.fields).filter(([, v]) => v.kind !== "literal").map(([k, v]) => `${k}: ${mapType(v, resolve)}`).join("; ")} }`;
8590
+ return `{ ${Object.entries(type.fields).filter(([, v]) => v.kind !== "literal").map(([k, v]) => `${objKey(k)}: ${mapType(v, resolve)}`).join("; ")} }`;
6600
8591
  }
6601
8592
  case "union": {
6602
8593
  const name = resolve(type);
@@ -6758,10 +8749,6 @@ function accessOf(binding, arg) {
6758
8749
  * carrying a Boutiques default. Restricted to single-segment (root) field access
6759
8750
  * so a nested field can never accidentally pick up a same-named root default.
6760
8751
  */
6761
- function rootFieldDefault$1(binding, defaults) {
6762
- const a = binding.access;
6763
- if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
6764
- }
6765
8752
  /**
6766
8753
  * Render a binding's access for an UNCONDITIONAL value read (terminal, repeat
6767
8754
  * loop, alternative dispatch): substitutes the field's default via
@@ -6770,22 +8757,9 @@ function rootFieldDefault$1(binding, defaults) {
6770
8757
  * emitted code is byte-identical to before for the common case.
6771
8758
  */
6772
8759
  function readAccess(binding, arg) {
6773
- const def = rootFieldDefault$1(binding, arg.defaults);
8760
+ const def = rootFieldDefault(binding, arg.defaults);
6774
8761
  return def !== void 0 ? `(${accessOf(binding, arg)} ?? ${def})` : accessOf(binding, arg);
6775
8762
  }
6776
- /** Build the field-name -> rendered-default map for a struct root (else empty).
6777
- * Includes only non-optional defaulted fields (optional fields are
6778
- * presence-guarded; their default comes from the factory's kwarg signature). */
6779
- function collectDefaults$1(ctx, rootType) {
6780
- const out = /* @__PURE__ */ new Map();
6781
- if (rootType?.kind !== "struct") return out;
6782
- for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
6783
- if (fi.defaultValue === void 0) continue;
6784
- if (rootType.fields[name]?.kind === "optional") continue;
6785
- out.set(name, renderTsLiteral(fi.defaultValue));
6786
- }
6787
- return out;
6788
- }
6789
8763
  let loopVarCounter = 0;
6790
8764
  /**
6791
8765
  * Build arg-building code for an IR tree via recursive descent.
@@ -6798,7 +8772,7 @@ function buildArgs(rootExpr, ctx, rootType) {
6798
8772
  return walk$1(rootExpr, ctx, {
6799
8773
  joinDepth: 0,
6800
8774
  loopVars: /* @__PURE__ */ new Map(),
6801
- defaults: collectDefaults$1(ctx, rootType)
8775
+ defaults: collectDefaults(ctx, renderTsLiteral, rootType)
6802
8776
  });
6803
8777
  }
6804
8778
  function walk$1(node, ctx, arg) {
@@ -6953,7 +8927,7 @@ function walkAlternative(node, ctx, arg) {
6953
8927
  //#region src/backend/typescript/emit.ts
6954
8928
  function emitJsDoc(cb, description) {
6955
8929
  if (!description) return;
6956
- const lines = description.split("\n");
8930
+ const lines = description.replace(/\*\//g, "*\\/").split("\n");
6957
8931
  if (lines.length === 1) cb.line(`/** ${lines[0]} */`);
6958
8932
  else {
6959
8933
  cb.line("/**");
@@ -6961,15 +8935,14 @@ function emitJsDoc(cb, description) {
6961
8935
  cb.line(" */");
6962
8936
  }
6963
8937
  }
6964
- function emitImports(cb, emitOutputs) {
6965
- const inputs = [
8938
+ function emitImports(cb) {
8939
+ cb.line(`import type { ${[
6966
8940
  "Runner",
6967
8941
  "Execution",
6968
8942
  "Metadata",
6969
- "InputPathType"
6970
- ];
6971
- if (emitOutputs) inputs.push("OutputPathType");
6972
- cb.line(`import type { ${inputs.join(", ")} } from "styxdefs";`);
8943
+ "InputPathType",
8944
+ "OutputPathType"
8945
+ ].join(", ")} } from "styxdefs";`);
6973
8946
  cb.line("import { getGlobalRunner, StyxValidationError } from \"styxdefs\";");
6974
8947
  }
6975
8948
  function emitMetadata(ctx, metaConst, cb) {
@@ -7313,7 +9286,7 @@ function emitValue(e, type, node, wireKey, access, expected) {
7313
9286
  const itemNode = findRepeatNode(node)?.attrs.node;
7314
9287
  const elem = e.scope.add("el");
7315
9288
  e.cb.line(`for (const ${elem} of ${access}) {`);
7316
- e.cb.indent(() => emitValue(e, type.item, itemNode, wireKey, elem, expected));
9289
+ e.cb.indent(() => emitValue(e, type.item, itemNode, wireKey, elem, expectedType(e, type.item)));
7317
9290
  e.cb.line("}");
7318
9291
  return;
7319
9292
  }
@@ -7448,26 +9421,6 @@ function emitOutputsInterface(ctx, outputsType, cb) {
7448
9421
  });
7449
9422
  cb.line(`}`);
7450
9423
  }
7451
- /** The rendered default for a binding iff it is a root-level defaulted field. */
7452
- function rootFieldDefault(binding, defaults) {
7453
- if (!binding) return void 0;
7454
- const a = binding.access;
7455
- if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
7456
- }
7457
- /** Build the field-name -> rendered-default map for the struct root (else empty).
7458
- * Includes only non-optional defaulted fields (optional fields are
7459
- * presence-guarded; their default comes from the factory's kwarg signature). */
7460
- function collectDefaults(ctx) {
7461
- const out = /* @__PURE__ */ new Map();
7462
- const rootType = ctx.resolve(ctx.expr)?.type;
7463
- if (rootType?.kind !== "struct") return out;
7464
- for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
7465
- if (fi.defaultValue === void 0) continue;
7466
- if (rootType.fields[name]?.kind === "optional") continue;
7467
- out.set(name, renderTsLiteral(fi.defaultValue));
7468
- }
7469
- return out;
7470
- }
7471
9424
  /**
7472
9425
  * Render one output's wrapper stack and emit the assignment inside the
7473
9426
  * innermost wrapper. Nesting is done via recursive callbacks so the
@@ -7511,10 +9464,15 @@ function renderWrapperOpen(atom, ec) {
7511
9464
  loopVar: v
7512
9465
  };
7513
9466
  }
7514
- if (atom.kind === "variant") return {
7515
- open: `if (${bindingAccess(atom.binding, ec)}["@type"] === ${JSON.stringify(atom.variant)}) {`,
7516
- close: `}`
7517
- };
9467
+ if (atom.kind === "variant") {
9468
+ const access = bindingAccess(atom.binding, ec);
9469
+ const check = `${access}["@type"] === ${JSON.stringify(atom.variant)}`;
9470
+ const union = variantAtomUnion(atom, ec.ctx.bindings);
9471
+ return {
9472
+ open: `if (${union && unionIsMixed(union) ? `typeof ${access} === "object" && ${access} !== null && ${check}` : check}) {`,
9473
+ close: `}`
9474
+ };
9475
+ }
7518
9476
  const binding = ec.ctx.bindings.get(atom.binding);
7519
9477
  const access = bindingAccess(atom.binding, ec);
7520
9478
  return {
@@ -7586,7 +9544,7 @@ function emitBuildOutputs(ctx, paramsType, outputsType, funcName, cb) {
7586
9544
  ctx,
7587
9545
  iter: /* @__PURE__ */ new Map(),
7588
9546
  fieldShapes: new Map(fields.map((f) => [f.id, f.shape])),
7589
- defaults: collectDefaults(ctx)
9547
+ defaults: collectDefaults(ctx, renderTsLiteral)
7590
9548
  };
7591
9549
  cb.line(`const outputs: ${outputsType} = {`);
7592
9550
  cb.indent(() => {
@@ -7756,13 +9714,22 @@ function buildEmitModel$1(ctx, scope = new Scope(TS_RESERVED)) {
7756
9714
  };
7757
9715
  }
7758
9716
  function generateTypeScript(ctx, packageScope) {
9717
+ return generateTypeScriptModule(ctx, packageScope).code;
9718
+ }
9719
+ /**
9720
+ * Emit the module and, alongside it, the dispatch entrypoint carrying the
9721
+ * *scope-registered* execute-function name. Computing the entrypoint here (not
9722
+ * via the scope-blind `appEntrypoint`) keeps the suite dispatcher in sync with
9723
+ * the actual emitted symbol when a shared package scope suffix-bumps a collision.
9724
+ */
9725
+ function generateTypeScriptModule(ctx, packageScope) {
7759
9726
  const cb = new CodeBuilder(" ");
7760
9727
  const scope = packageScope ?? new Scope(TS_RESERVED);
7761
9728
  const { appId, pkg, names, rootType, rootIsStruct, namedTypes, typeDecls, rootTypeTag, paramsType, sigEntries } = buildEmitModel$1(ctx, scope);
7762
9729
  cb.comment("This file was auto generated by Styx.");
7763
9730
  cb.comment("Do not edit this file directly.");
7764
9731
  cb.blank();
7765
- emitImports(cb, true);
9732
+ emitImports(cb);
7766
9733
  cb.blank();
7767
9734
  emitMetadata(ctx, names.metadata, cb);
7768
9735
  cb.blank();
@@ -7783,13 +9750,23 @@ function generateTypeScript(ctx, packageScope) {
7783
9750
  cb.blank();
7784
9751
  emitBuildOutputs(ctx, paramsType, names.outputs, names.outputsFn, cb);
7785
9752
  cb.blank();
7786
- emitWrapperFunction(ctx, paramsType, rootIsStruct ? names.execute : names.wrapper, names.metadata, names.cargs, names.outputsFn, names.outputs, names.validate, streamFieldIds(ctx), cb);
9753
+ const executeName = rootIsStruct ? names.execute : names.wrapper;
9754
+ emitWrapperFunction(ctx, paramsType, executeName, names.metadata, names.cargs, names.outputsFn, names.outputs, names.validate, streamFieldIds(ctx), cb);
7787
9755
  cb.blank();
7788
9756
  if (rootIsStruct) {
7789
9757
  emitKwargWrapper(ctx, sigEntries, names.wrapper, names.paramsFn, names.execute, names.outputs, cb);
7790
9758
  cb.blank();
7791
9759
  }
7792
- return cb.toString();
9760
+ const entryAppId = ctx.app?.id;
9761
+ const entryPkg = ctx.package?.name;
9762
+ const entrypoint = entryAppId && entryPkg ? {
9763
+ type: `${entryPkg}/${entryAppId}`,
9764
+ executeFn: executeName
9765
+ } : void 0;
9766
+ return {
9767
+ code: cb.toString(),
9768
+ entrypoint
9769
+ };
7793
9770
  }
7794
9771
  /**
7795
9772
  * Module name (file stem) for an app: snake_case of app.id, fallback `output`.
@@ -7871,11 +9848,11 @@ var TypeScriptBackend = class {
7871
9848
  name = "typescript";
7872
9849
  target = "typescript";
7873
9850
  emitApp(ctx, scope) {
7874
- const code = generateTypeScript(ctx, scope);
9851
+ const { code, entrypoint } = generateTypeScriptModule(ctx, scope);
7875
9852
  const fileName = `${appModuleName(ctx.app)}.ts`;
7876
9853
  return {
7877
9854
  meta: ctx.app,
7878
- entrypoint: appEntrypoint(ctx),
9855
+ entrypoint,
7879
9856
  files: new Map([[fileName, code]]),
7880
9857
  errors: [],
7881
9858
  warnings: []
@@ -8141,6 +10118,15 @@ const canonicalize = {
8141
10118
  default: return "";
8142
10119
  }
8143
10120
  }
10121
+ function identityKey(node) {
10122
+ const m = node.meta;
10123
+ const metaKey = m ? [
10124
+ m.name ?? "",
10125
+ m.variantTag ?? "",
10126
+ m.outputs ? JSON.stringify(m.outputs) : ""
10127
+ ].join("|") : "";
10128
+ return `${structuralHash(node)}#${metaKey}`;
10129
+ }
8144
10130
  function sortKey(node) {
8145
10131
  const name = node.meta?.name ?? "";
8146
10132
  return `${node.kind}:${name}:${structuralHash(node)}`;
@@ -8153,13 +10139,13 @@ const canonicalize = {
8153
10139
  const seen = /* @__PURE__ */ new Set();
8154
10140
  const alts = [];
8155
10141
  for (const child of sorted) {
8156
- const hash = structuralHash(child);
8157
- if (!seen.has(hash)) {
8158
- seen.add(hash);
10142
+ const key = identityKey(child);
10143
+ if (!seen.has(key)) {
10144
+ seen.add(key);
8159
10145
  alts.push(child);
8160
10146
  } else changed = true;
8161
10147
  }
8162
- if (alts.length !== children.length || alts.some((alt, i) => structuralHash(alt) !== structuralHash(children[i]))) changed = true;
10148
+ if (alts.length !== children.length || alts.some((alt, i) => identityKey(alt) !== identityKey(children[i]))) changed = true;
8163
10149
  return {
8164
10150
  ...node,
8165
10151
  attrs: {
@@ -8442,11 +10428,24 @@ const simplify = {
8442
10428
  const prev = nodes[nodes.length - 1];
8443
10429
  if (prev?.kind === "literal" && child.kind === "literal" && !prev.meta && !child.meta && node.attrs.join === "") {
8444
10430
  changed = true;
8445
- prev.attrs.str += child.attrs.str;
10431
+ nodes[nodes.length - 1] = {
10432
+ ...prev,
10433
+ attrs: {
10434
+ ...prev.attrs,
10435
+ str: prev.attrs.str + child.attrs.str
10436
+ }
10437
+ };
8446
10438
  } else nodes.push(child);
8447
10439
  }
8448
10440
  if (nodes.length === 1) {
8449
10441
  const child = nodes[0];
10442
+ if (node.meta?.outputs?.length && child.kind === "literal") return {
10443
+ ...node,
10444
+ attrs: {
10445
+ ...node.attrs,
10446
+ nodes
10447
+ }
10448
+ };
8450
10449
  changed = true;
8451
10450
  const mergedMeta = mergeMeta(node.meta, child.meta);
8452
10451
  return mergedMeta ? {
@@ -8864,7 +10863,7 @@ function defaultNamingStrategy() {
8864
10863
  function isBooleanLiteralPair(variants) {
8865
10864
  if (variants.length !== 2 || !variants.every((v) => v.type.kind === "literal")) return false;
8866
10865
  const [a, b] = variants.map((v) => v.type.kind === "literal" ? v.type.value : null);
8867
- return a === 0 && b === 1 || a === 1 && b === 0 || a === "0" && b === "1" || a === "1" && b === "0" || a === "false" && b === "true" || a === "true" && b === "false";
10866
+ return a === 0 && b === 1 || a === 1 && b === 0 || a === "false" && b === "true" || a === "true" && b === "false";
8868
10867
  }
8869
10868
  function literalFromNode(node) {
8870
10869
  const str = node.attrs.str;
@@ -9090,7 +11089,8 @@ function solve(expr, options) {
9090
11089
  //#region src/index.ts
9091
11090
  function compile(source, filenameOrOptions) {
9092
11091
  const options = typeof filenameOrOptions === "string" ? { filename: filenameOrOptions } : filenameOrOptions ?? {};
9093
- const format = options.format ?? detectFormat(source);
11092
+ const byExtension = options.filename?.endsWith(".argtype") ? "argtype" : void 0;
11093
+ const format = options.format ?? byExtension ?? detectFormat(source);
9094
11094
  if (!format) return {
9095
11095
  expr: {
9096
11096
  kind: "sequence",
@@ -9099,10 +11099,20 @@ function compile(source, filenameOrOptions) {
9099
11099
  errors: [{ message: "Could not detect input format. Specify format explicitly." }],
9100
11100
  warnings: []
9101
11101
  };
9102
- return (format === "argdump" ? new ArgdumpParser() : format === "workbench" ? new WorkbenchParser() : format === "mrtrix" ? new MrtrixParser() : new BoutiquesParser()).parse(source, options.filename);
11102
+ return (() => {
11103
+ switch (format) {
11104
+ case "argdump": return new ArgdumpParser();
11105
+ case "argtype": return new ArgtypeParser();
11106
+ case "workbench": return new WorkbenchParser();
11107
+ case "mrtrix": return new MrtrixParser();
11108
+ case "boutiques": return new BoutiquesParser();
11109
+ default: return new BoutiquesParser();
11110
+ }
11111
+ })().parse(source, options.filename);
9103
11112
  }
9104
11113
 
9105
11114
  //#endregion
11115
+ exports.ArgtypeBackend = ArgtypeBackend;
9106
11116
  exports.BoutiquesBackend = BoutiquesBackend;
9107
11117
  exports.CodeBuilder = CodeBuilder;
9108
11118
  exports.JsonSchemaBackend = JsonSchemaBackend;
@@ -9124,7 +11134,6 @@ exports.camelCase = camelCase;
9124
11134
  exports.canonicalize = canonicalize;
9125
11135
  exports.collectFieldInfo = collectFieldInfo;
9126
11136
  exports.collectNamedTypes = collectNamedTypes;
9127
- exports.compactTokens = compactTokens;
9128
11137
  exports.compile = compile;
9129
11138
  exports.compose = compose;
9130
11139
  exports.createContext = createContext;
@@ -9141,6 +11150,7 @@ exports.flatten = flatten;
9141
11150
  exports.float = float;
9142
11151
  exports.format = format;
9143
11152
  exports.formatSolveResult = formatSolveResult;
11153
+ exports.generateArgtype = generateArgtype;
9144
11154
  exports.generateBoutiques = generateBoutiques;
9145
11155
  exports.generateNipype = generateNipype;
9146
11156
  exports.generateOutputsSchema = generateOutputsSchema;
@@ -9149,8 +11159,6 @@ exports.generatePython = generatePython;
9149
11159
  exports.generateSchema = generateSchema;
9150
11160
  exports.generateTypeScript = generateTypeScript;
9151
11161
  exports.int = int;
9152
- exports.isGated = isGated;
9153
- exports.isIterated = isIterated;
9154
11162
  exports.isStructural = isStructural;
9155
11163
  exports.isTerminal = isTerminal;
9156
11164
  exports.lit = lit;
@@ -9160,8 +11168,6 @@ exports.opt = opt;
9160
11168
  exports.outputGate = outputGate;
9161
11169
  exports.pascalCase = pascalCase;
9162
11170
  exports.path = path;
9163
- exports.planOutput = planOutput;
9164
- exports.planScope = planScope;
9165
11171
  exports.pydraNames = pydraNames;
9166
11172
  exports.removeEmpty = removeEmpty;
9167
11173
  exports.renderPythonCall = renderPythonCall;