@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
@@ -20,29 +20,36 @@ export function destructTemplate<T>(template: string, lookup: Record<string, T>)
20
20
  continue;
21
21
  }
22
22
 
23
- let didSplit = false;
24
-
23
+ // Pick the leftmost match; on a tie in position, the longest alias wins
24
+ // (maximal munch), so a value-key that is a prefix of another (e.g. `foo`
25
+ // vs `foobar`) does not shadow it by mere iteration order.
26
+ let best: { idx: number; alias: string; replacement: T } | null = null;
25
27
  for (const [alias, replacement] of Object.entries(lookup)) {
28
+ if (alias.length === 0) continue;
26
29
  const idx = x.indexOf(alias);
27
- if (idx !== -1) {
28
- const left = x.slice(0, idx);
29
- const right = x.slice(idx + alias.length);
30
-
31
- if (right.length > 0) {
32
- stack.unshift(right);
33
- }
34
- stack.unshift(replacement);
35
- if (left.length > 0) {
36
- stack.unshift(left);
37
- }
38
-
39
- didSplit = true;
40
- break;
30
+ if (idx === -1) continue;
31
+ if (
32
+ best === null ||
33
+ idx < best.idx ||
34
+ (idx === best.idx && alias.length > best.alias.length)
35
+ ) {
36
+ best = { idx, alias, replacement };
41
37
  }
42
38
  }
43
39
 
44
- if (!didSplit) {
40
+ if (best === null) {
45
41
  destructed.push(x);
42
+ continue;
43
+ }
44
+
45
+ const left = x.slice(0, best.idx);
46
+ const right = x.slice(best.idx + best.alias.length);
47
+ if (right.length > 0) {
48
+ stack.unshift(right);
49
+ }
50
+ stack.unshift(best.replacement);
51
+ if (left.length > 0) {
52
+ stack.unshift(left);
46
53
  }
47
54
  }
48
55
 
@@ -372,12 +372,21 @@ export class BoutiquesParser implements Frontend {
372
372
  case InputTypePrimitive.Integer: {
373
373
  const node: Int = { kind: "int", attrs: {} };
374
374
  if (isNumber(btInput.minimum)) {
375
- node.attrs.minValue = Math.floor(btInput.minimum);
376
- if (btInput["exclusive-minimum"] === true) node.attrs.minValue += 1;
375
+ // Smallest valid integer: for a fractional bound, `>= 5.7` allows 6
376
+ // (ceil) and `> 5.7` allows 6 (floor + 1); both differ from a plain
377
+ // floor.
378
+ node.attrs.minValue =
379
+ btInput["exclusive-minimum"] === true
380
+ ? Math.floor(btInput.minimum) + 1
381
+ : Math.ceil(btInput.minimum);
377
382
  }
378
383
  if (isNumber(btInput.maximum)) {
379
- node.attrs.maxValue = Math.floor(btInput.maximum);
380
- if (btInput["exclusive-maximum"] === true) node.attrs.maxValue -= 1;
384
+ // Largest valid integer: `<= 5.7` allows 5 (floor) and `< 5.7` allows
385
+ // 5 (ceil - 1).
386
+ node.attrs.maxValue =
387
+ btInput["exclusive-maximum"] === true
388
+ ? Math.ceil(btInput.maximum) - 1
389
+ : Math.floor(btInput.maximum);
381
390
  }
382
391
  if (meta) node.meta = meta;
383
392
  return node;
@@ -424,7 +433,10 @@ export class BoutiquesParser implements Frontend {
424
433
  return null;
425
434
  }
426
435
  const node = this.parseDescriptor(nested);
427
- if (node && meta) node.meta = meta;
436
+ // Merge (not overwrite) so the subcommand's own meta - notably its
437
+ // `output-files`, attached by `parseDescriptor` - survives; the input's
438
+ // meta (name/doc) layers on top.
439
+ if (node && meta) node.meta = { ...node.meta, ...meta };
428
440
  return node;
429
441
  }
430
442
 
@@ -553,12 +565,19 @@ export class BoutiquesParser implements Frontend {
553
565
  }
554
566
 
555
567
  // Hoist metadata (doc, default) to outermost wrapper so backends find it
556
- // on the binding node. Keep name on inner for solver's findDeepName.
568
+ // on the binding node. Keep `name` on inner for the solver's findDeepName,
569
+ // and keep `outputs` there too: outputs are a scope concept declared on the
570
+ // subcommand struct, not a property of the list/flag wrapper - hoisting them
571
+ // onto a repeat would move the output scope off the struct and make backends
572
+ // (which resolve outputs against the struct) silently drop them.
557
573
  if (node !== inner && inner.meta) {
558
- const { name, ...rest } = inner.meta;
574
+ const { name, outputs, ...rest } = inner.meta;
559
575
  if (Object.keys(rest).length > 0) {
560
576
  node.meta = { ...node.meta, ...rest };
561
- inner.meta = name ? { name } : undefined;
577
+ const kept: NodeMeta = {};
578
+ if (name) kept.name = name;
579
+ if (outputs) kept.outputs = outputs;
580
+ inner.meta = Object.keys(kept).length > 0 ? kept : undefined;
562
581
  }
563
582
  }
564
583
 
@@ -1,14 +1,39 @@
1
- export type FormatName = "boutiques" | "argdump" | "workbench" | "mrtrix";
1
+ export type FormatName = "boutiques" | "argdump" | "workbench" | "mrtrix" | "argtype";
2
+
3
+ /** The first non-whitespace character of a source ("" if it is all blank). */
4
+ function firstNonBlankChar(source: string): string {
5
+ const match = source.match(/\S/);
6
+ return match ? match[0] : "";
7
+ }
2
8
 
3
9
  /**
4
- * Auto-detect the format of a JSON descriptor source string.
5
- * Returns null if the format cannot be determined.
10
+ * Auto-detect the format of a descriptor source string.
11
+ *
12
+ * Every JSON frontend (boutiques, argdump, workbench, mrtrix) is a top-level
13
+ * object, so the first non-blank character being `{` is the signal for "some
14
+ * JSON format"; we then inspect its keys to pick which one. Anything else
15
+ * non-blank is treated as the argtype DSL, whose sources open with a terminal
16
+ * (`int`), a literal (`"hello"`), a combinator (`seq(...)`), a `name: expr`
17
+ * definition, or a `---` frontmatter fence - never with `{`.
18
+ *
19
+ * Deciding on the leading character (rather than trying `JSON.parse` first) is
20
+ * what lets standalone argtype snippets like `"hello"` or `42` be recognized:
21
+ * those are *also* valid JSON scalars, so a parse-first approach would swallow
22
+ * them and then reject them as "not an object".
23
+ *
24
+ * Returns null only when the source is blank, or opens with `{` but matches no
25
+ * known JSON format (ambiguous, or still being typed).
6
26
  */
7
27
  export function detectFormat(source: string): FormatName | null {
28
+ const first = firstNonBlankChar(source);
29
+ if (first === "") return null;
30
+ if (first !== "{") return "argtype";
31
+
8
32
  let parsed: unknown;
9
33
  try {
10
34
  parsed = JSON.parse(source);
11
35
  } catch {
36
+ // Opens like a JSON object but is not valid (yet) - e.g. mid-edit.
12
37
  return null;
13
38
  }
14
39
 
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ArgdumpParser } from "./frontend/argdump/index.js";
2
+ import { ArgtypeParser } from "./frontend/argtype/index.js";
2
3
  import { BoutiquesParser } from "./frontend/boutiques/index.js";
3
4
  import { MrtrixParser } from "./frontend/mrtrix/index.js";
4
5
  import { WorkbenchParser } from "./frontend/workbench/index.js";
@@ -22,7 +23,10 @@ export function compile(
22
23
  ? { filename: filenameOrOptions }
23
24
  : (filenameOrOptions ?? {});
24
25
 
25
- const format = options.format ?? detectFormat(source);
26
+ // An explicit `.argtype` filename selects the DSL frontend even though the
27
+ // source is not JSON (detection by content is a fallback).
28
+ const byExtension = options.filename?.endsWith(".argtype") ? "argtype" : undefined;
29
+ const format = options.format ?? byExtension ?? detectFormat(source);
26
30
 
27
31
  if (!format) {
28
32
  return {
@@ -32,13 +36,23 @@ export function compile(
32
36
  };
33
37
  }
34
38
 
35
- const parser =
36
- format === "argdump"
37
- ? new ArgdumpParser()
38
- : format === "workbench"
39
- ? new WorkbenchParser()
40
- : format === "mrtrix"
41
- ? new MrtrixParser()
42
- : new BoutiquesParser();
39
+ const parser = ((): { parse: (s: string, f?: string) => ParseResult } => {
40
+ switch (format) {
41
+ case "argdump":
42
+ return new ArgdumpParser();
43
+ case "argtype":
44
+ return new ArgtypeParser();
45
+ case "workbench":
46
+ return new WorkbenchParser();
47
+ case "mrtrix":
48
+ return new MrtrixParser();
49
+ case "boutiques":
50
+ return new BoutiquesParser();
51
+ default: {
52
+ const _exhaustive: never = format;
53
+ return new BoutiquesParser();
54
+ }
55
+ }
56
+ })();
43
57
  return parser.parse(source, options.filename);
44
58
  }
@@ -38,6 +38,19 @@ export const canonicalize: Pass = {
38
38
  }
39
39
  }
40
40
 
41
+ // Identity for dedup: structure plus the meta that makes two structurally
42
+ // identical union arms semantically distinct - the variant tag (@type
43
+ // discriminant), name, and attached outputs. Deduping on structure alone
44
+ // would silently drop a distinct arm (e.g. two sub-commands with the same
45
+ // inner shape but different @type), making its variant unreachable.
46
+ function identityKey(node: Expr): string {
47
+ const m = node.meta;
48
+ const metaKey = m
49
+ ? [m.name ?? "", m.variantTag ?? "", m.outputs ? JSON.stringify(m.outputs) : ""].join("|")
50
+ : "";
51
+ return `${structuralHash(node)}#${metaKey}`;
52
+ }
53
+
41
54
  function sortKey(node: Expr): string {
42
55
  const name = node.meta?.name ?? "";
43
56
  return `${node.kind}:${name}:${structuralHash(node)}`;
@@ -51,13 +64,13 @@ export const canonicalize: Pass = {
51
64
  // Sort alternatives
52
65
  const sorted = [...children].sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
53
66
 
54
- // Deduplicate by structural hash
67
+ // Deduplicate identical arms (structure + discriminating meta).
55
68
  const seen = new Set<string>();
56
69
  const alts: Expr[] = [];
57
70
  for (const child of sorted) {
58
- const hash = structuralHash(child);
59
- if (!seen.has(hash)) {
60
- seen.add(hash);
71
+ const key = identityKey(child);
72
+ if (!seen.has(key)) {
73
+ seen.add(key);
61
74
  alts.push(child);
62
75
  } else {
63
76
  changed = true;
@@ -67,7 +80,7 @@ export const canonicalize: Pass = {
67
80
  // Check if order changed
68
81
  if (
69
82
  alts.length !== children.length ||
70
- alts.some((alt, i) => structuralHash(alt) !== structuralHash(children[i]!))
83
+ alts.some((alt, i) => identityKey(alt) !== identityKey(children[i]!))
71
84
  ) {
72
85
  changed = true;
73
86
  }
@@ -127,15 +127,30 @@ export const simplify: Pass = {
127
127
  node.attrs.join === ""
128
128
  ) {
129
129
  changed = true;
130
- prev.attrs.str += child.attrs.str;
130
+ // Replace with a fresh node - `prev` is the original literal object
131
+ // (the `literal` case returns `node` unchanged), so mutating it in
132
+ // place would corrupt the caller's IR and double-append on rerun.
133
+ nodes[nodes.length - 1] = {
134
+ ...prev,
135
+ attrs: { ...prev.attrs, str: prev.attrs.str + child.attrs.str },
136
+ };
131
137
  } else {
132
138
  nodes.push(child);
133
139
  }
134
140
  }
135
141
 
136
- // seq(T) -> T, carrying the seq's meta down onto the child.
142
+ // seq(T) -> T, carrying the seq's meta down onto the child. Skip the
143
+ // collapse when the sequence declares outputs and the sole child is a
144
+ // literal: outputs need a scope binding, which the solver only forces
145
+ // on sequences (not on literals, which are never bound). Collapsing a
146
+ // parameterless output-bearing command (e.g. `seq(lit("run"))` with an
147
+ // output-file) onto its literal would orphan the output. A field child
148
+ // (str/path) does get bound, so those still collapse.
137
149
  if (nodes.length === 1) {
138
150
  const child = nodes[0]!;
151
+ if (node.meta?.outputs?.length && child.kind === "literal") {
152
+ return { ...node, attrs: { ...node.attrs, nodes } };
153
+ }
139
154
  changed = true;
140
155
  const mergedMeta = mergeMeta(node.meta, child.meta);
141
156
  return mergedMeta ? { ...child, meta: mergedMeta } : child;
@@ -45,11 +45,11 @@ function isBooleanLiteralPair(variants: Array<{ type: BoundType }>): boolean {
45
45
  return false;
46
46
  }
47
47
  const [a, b] = variants.map((v) => (v.type.kind === "literal" ? v.type.value : null));
48
+ // `literalFromNode` canonicalizes clean-int literals, so "0"/"1" arrive as the
49
+ // numbers 0/1 (never strings); only "false"/"true" survive as strings.
48
50
  return (
49
51
  (a === 0 && b === 1) ||
50
52
  (a === 1 && b === 0) ||
51
- (a === "0" && b === "1") ||
52
- (a === "1" && b === "0") ||
53
53
  (a === "false" && b === "true") ||
54
54
  (a === "true" && b === "false")
55
55
  );