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