@styx-api/core 0.1.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.
- package/dist/index.cjs +7947 -0
- package/dist/index.d.cts +1143 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +1143 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +7877 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +55 -0
- package/src/backend/backend.ts +95 -0
- package/src/backend/boutiques/boutiques.ts +1049 -0
- package/src/backend/boutiques/index.ts +1 -0
- package/src/backend/code-builder.ts +49 -0
- package/src/backend/collect-field-info.ts +50 -0
- package/src/backend/collect-named-types.ts +103 -0
- package/src/backend/collect-output-fields.ts +222 -0
- package/src/backend/find-doc.ts +38 -0
- package/src/backend/find-struct-node.ts +66 -0
- package/src/backend/index.ts +39 -0
- package/src/backend/python/arg-builder.ts +454 -0
- package/src/backend/python/emit.ts +638 -0
- package/src/backend/python/index.ts +9 -0
- package/src/backend/python/outputs-emit.ts +430 -0
- package/src/backend/python/packaging.ts +173 -0
- package/src/backend/python/python.ts +558 -0
- package/src/backend/python/snippet.ts +84 -0
- package/src/backend/python/typemap.ts +131 -0
- package/src/backend/python/types.ts +8 -0
- package/src/backend/python/validate-emit.ts +356 -0
- package/src/backend/resolve-field-binding.ts +41 -0
- package/src/backend/resolve-output-tokens.ts +80 -0
- package/src/backend/schema/index.ts +2 -0
- package/src/backend/schema/jsonschema.ts +303 -0
- package/src/backend/scope.ts +50 -0
- package/src/backend/sig-entries.ts +97 -0
- package/src/backend/snippet-core.ts +185 -0
- package/src/backend/string-case.ts +30 -0
- package/src/backend/styxdefs-compat.ts +21 -0
- package/src/backend/type-keys.ts +52 -0
- package/src/backend/typescript/arg-builder.ts +420 -0
- package/src/backend/typescript/emit.ts +450 -0
- package/src/backend/typescript/index.ts +10 -0
- package/src/backend/typescript/outputs-emit.ts +389 -0
- package/src/backend/typescript/packaging.ts +130 -0
- package/src/backend/typescript/snippet.ts +60 -0
- package/src/backend/typescript/typemap.ts +47 -0
- package/src/backend/typescript/types.ts +8 -0
- package/src/backend/typescript/typescript.ts +507 -0
- package/src/backend/typescript/validate-emit.ts +341 -0
- package/src/backend/union-variants.ts +42 -0
- package/src/backend/validate-walk.ts +111 -0
- package/src/bindings/binding.ts +77 -0
- package/src/bindings/format.ts +176 -0
- package/src/bindings/index.ts +16 -0
- package/src/bindings/output-gate.ts +50 -0
- package/src/bindings/resolved-output.ts +56 -0
- package/src/bindings/types.ts +16 -0
- package/src/frontend/argdump/index.ts +1 -0
- package/src/frontend/argdump/parser.ts +914 -0
- package/src/frontend/boutiques/destruct-template.ts +50 -0
- package/src/frontend/boutiques/index.ts +1 -0
- package/src/frontend/boutiques/parser.ts +676 -0
- package/src/frontend/boutiques/split-command.ts +69 -0
- package/src/frontend/detect-format.ts +42 -0
- package/src/frontend/frontend.ts +31 -0
- package/src/frontend/index.ts +9 -0
- package/src/frontend/workbench/index.ts +1 -0
- package/src/frontend/workbench/parser.ts +351 -0
- package/src/index.ts +41 -0
- package/src/ir/builders.ts +69 -0
- package/src/ir/format.ts +157 -0
- package/src/ir/index.ts +32 -0
- package/src/ir/meta.ts +91 -0
- package/src/ir/node.ts +95 -0
- package/src/ir/passes/canonicalize.ts +108 -0
- package/src/ir/passes/flatten.ts +73 -0
- package/src/ir/passes/index.ts +7 -0
- package/src/ir/passes/pass.ts +86 -0
- package/src/ir/passes/pipeline.ts +21 -0
- package/src/ir/passes/remove-empty.ts +76 -0
- package/src/ir/passes/simplify.ts +179 -0
- package/src/ir/types.ts +15 -0
- package/src/manifest/context.ts +36 -0
- package/src/manifest/index.ts +3 -0
- package/src/manifest/types.ts +15 -0
- package/src/solver/assign-access.ts +218 -0
- package/src/solver/index.ts +4 -0
- package/src/solver/resolve-outputs.ts +233 -0
- package/src/solver/solver.ts +319 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import type { Binding, BoundType, BoundVariant } from "../../bindings/index.js";
|
|
2
|
+
import type { Expr, ScalarKind } from "../../ir/index.js";
|
|
3
|
+
import type { CodegenContext } from "../../manifest/index.js";
|
|
4
|
+
import type { Backend, EmittedApp } from "../backend.js";
|
|
5
|
+
import { type OutputShape, collectOutputFields, streamFields } from "../collect-output-fields.js";
|
|
6
|
+
import { findDoc } from "../find-doc.js";
|
|
7
|
+
import { findStructNode } from "../find-struct-node.js";
|
|
8
|
+
import { resolveFieldBinding } from "../resolve-field-binding.js";
|
|
9
|
+
import { Scope } from "../scope.js";
|
|
10
|
+
import { snakeCase } from "../string-case.js";
|
|
11
|
+
|
|
12
|
+
export interface JsonSchema {
|
|
13
|
+
type?: string | string[];
|
|
14
|
+
items?: JsonSchema;
|
|
15
|
+
properties?: Record<string, JsonSchema>;
|
|
16
|
+
required?: string[];
|
|
17
|
+
oneOf?: JsonSchema[];
|
|
18
|
+
enum?: (string | number)[];
|
|
19
|
+
const?: string | number;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class SchemaBuilder {
|
|
24
|
+
constructor(private ctx: CodegenContext) {}
|
|
25
|
+
|
|
26
|
+
build(): JsonSchema {
|
|
27
|
+
const envelope: JsonSchema = {
|
|
28
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
29
|
+
};
|
|
30
|
+
if (this.ctx.app?.doc?.title) envelope.title = this.ctx.app.doc.title;
|
|
31
|
+
if (this.ctx.app?.doc?.description) envelope.description = this.ctx.app.doc.description;
|
|
32
|
+
|
|
33
|
+
const rootBinding = this.ctx.resolve(this.ctx.expr);
|
|
34
|
+
if (!rootBinding) return envelope;
|
|
35
|
+
|
|
36
|
+
const schema = { ...envelope, ...this.fromBinding(rootBinding) };
|
|
37
|
+
|
|
38
|
+
if (this.ctx.app?.id && schema.properties) {
|
|
39
|
+
const pkg = this.ctx.package?.name ?? "unknown";
|
|
40
|
+
schema.properties = {
|
|
41
|
+
"@type": { const: `${pkg}/${this.ctx.app.id}` },
|
|
42
|
+
...schema.properties,
|
|
43
|
+
};
|
|
44
|
+
schema.required = ["@type", ...(schema.required ?? [])];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return schema;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private fromBinding(binding: Binding): JsonSchema {
|
|
51
|
+
const schema = this.fromType(binding.type, binding.node);
|
|
52
|
+
const meta = binding.node.meta;
|
|
53
|
+
if (meta?.doc?.title) schema.title = meta.doc.title;
|
|
54
|
+
if (meta?.doc?.description) schema.description = meta.doc.description;
|
|
55
|
+
if (meta?.defaultValue !== undefined) schema.default = meta.defaultValue;
|
|
56
|
+
return schema;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private fromType(type: BoundType, node?: Expr): JsonSchema {
|
|
60
|
+
switch (type.kind) {
|
|
61
|
+
case "scalar":
|
|
62
|
+
return this.scalarSchema(type.scalar, node);
|
|
63
|
+
case "bool":
|
|
64
|
+
return { type: "boolean" };
|
|
65
|
+
case "count":
|
|
66
|
+
return { type: "integer", minimum: 0 };
|
|
67
|
+
case "literal":
|
|
68
|
+
return { const: type.value };
|
|
69
|
+
case "optional":
|
|
70
|
+
return this.fromType(type.inner, node?.kind === "optional" ? node.attrs.node : undefined);
|
|
71
|
+
case "list": {
|
|
72
|
+
const repeat = node?.kind === "repeat" ? node : undefined;
|
|
73
|
+
const schema: JsonSchema = {
|
|
74
|
+
type: "array",
|
|
75
|
+
items: this.fromType(type.item, repeat?.attrs.node),
|
|
76
|
+
};
|
|
77
|
+
// Bounded/fixed-length vectors (e.g. a 3-element coordinate) carry length
|
|
78
|
+
// constraints so a form consumer can render the right number of slots.
|
|
79
|
+
if (repeat?.attrs.countMin !== undefined) schema.minItems = repeat.attrs.countMin;
|
|
80
|
+
if (repeat?.attrs.countMax !== undefined) schema.maxItems = repeat.attrs.countMax;
|
|
81
|
+
return schema;
|
|
82
|
+
}
|
|
83
|
+
case "struct":
|
|
84
|
+
return this.structSchema(type, node);
|
|
85
|
+
case "union":
|
|
86
|
+
return this.unionSchema(type);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private findTerminal(node: Expr): Expr {
|
|
91
|
+
switch (node.kind) {
|
|
92
|
+
case "optional":
|
|
93
|
+
return this.findTerminal(node.attrs.node);
|
|
94
|
+
case "repeat":
|
|
95
|
+
return this.findTerminal(node.attrs.node);
|
|
96
|
+
case "sequence": {
|
|
97
|
+
const nonLiteral = node.attrs.nodes.find((n) => n.kind !== "literal");
|
|
98
|
+
return nonLiteral ? this.findTerminal(nonLiteral) : node;
|
|
99
|
+
}
|
|
100
|
+
default:
|
|
101
|
+
return node;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private scalarSchema(scalar: ScalarKind, node?: Expr): JsonSchema {
|
|
106
|
+
const base: JsonSchema = {
|
|
107
|
+
int: { type: "integer" } as JsonSchema,
|
|
108
|
+
float: { type: "number" } as JsonSchema,
|
|
109
|
+
str: { type: "string" } as JsonSchema,
|
|
110
|
+
path: { type: "string", "x-styx-type": "path" } as JsonSchema,
|
|
111
|
+
}[scalar];
|
|
112
|
+
|
|
113
|
+
const terminal = node ? this.findTerminal(node) : undefined;
|
|
114
|
+
if (terminal && (terminal.kind === "int" || terminal.kind === "float")) {
|
|
115
|
+
if (terminal.attrs.minValue !== undefined) base.minimum = terminal.attrs.minValue;
|
|
116
|
+
if (terminal.attrs.maxValue !== undefined) base.maximum = terminal.attrs.maxValue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return base;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private structSchema(type: Extract<BoundType, { kind: "struct" }>, node?: Expr): JsonSchema {
|
|
123
|
+
const properties: Record<string, JsonSchema> = {};
|
|
124
|
+
const required: string[] = [];
|
|
125
|
+
|
|
126
|
+
// Use shared findStructNode for correct traversal through opt/rep/alt wrappers
|
|
127
|
+
const structNode = node ? findStructNode(node, this.ctx, type) : undefined;
|
|
128
|
+
if (structNode) {
|
|
129
|
+
for (const child of structNode.attrs.nodes) {
|
|
130
|
+
// Use shared resolveFieldBinding for correct collapsed-sequence handling
|
|
131
|
+
const match = resolveFieldBinding(child, this.ctx, type);
|
|
132
|
+
if (!match) continue;
|
|
133
|
+
const { binding, wrapperNode } = match;
|
|
134
|
+
|
|
135
|
+
const schema = this.fromType(binding.type, binding.node);
|
|
136
|
+
const fieldType = type.fields[binding.name]!;
|
|
137
|
+
|
|
138
|
+
// Use shared findDoc for correct doc propagation through collapsed sequences
|
|
139
|
+
const doc =
|
|
140
|
+
findDoc(wrapperNode, fieldType) ??
|
|
141
|
+
findDoc(binding.node, fieldType) ??
|
|
142
|
+
wrapperNode.meta?.doc?.description;
|
|
143
|
+
if (doc) schema.description = doc;
|
|
144
|
+
|
|
145
|
+
const title = wrapperNode.meta?.doc?.title ?? binding.node.meta?.doc?.title;
|
|
146
|
+
if (title) schema.title = title;
|
|
147
|
+
|
|
148
|
+
const defaultValue = wrapperNode.meta?.defaultValue ?? binding.node.meta?.defaultValue;
|
|
149
|
+
if (defaultValue !== undefined) schema.default = defaultValue;
|
|
150
|
+
|
|
151
|
+
properties[binding.name] = schema;
|
|
152
|
+
if (fieldType.kind !== "optional" && defaultValue === undefined) {
|
|
153
|
+
required.push(binding.name);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
for (const [name, fieldType] of Object.entries(type.fields)) {
|
|
158
|
+
properties[name] = this.fromType(fieldType);
|
|
159
|
+
if (fieldType.kind !== "optional") {
|
|
160
|
+
required.push(name);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const schema: JsonSchema = { type: "object", properties };
|
|
166
|
+
if (required.length > 0) schema.required = required;
|
|
167
|
+
return schema;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private unionSchema(type: Extract<BoundType, { kind: "union" }>): JsonSchema {
|
|
171
|
+
const allLiterals = type.variants.every((v: BoundVariant) => v.type.kind === "literal");
|
|
172
|
+
if (allLiterals) {
|
|
173
|
+
return {
|
|
174
|
+
enum: type.variants.map((v: BoundVariant) =>
|
|
175
|
+
v.type.kind === "literal" ? v.type.value : "",
|
|
176
|
+
),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return { oneOf: type.variants.map((v: BoundVariant) => this.fromType(v.type)) };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function generateSchema(ctx: CodegenContext): JsonSchema {
|
|
184
|
+
return new SchemaBuilder(ctx).build();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Output names are language-neutral in the schema; key by the raw descriptor id. */
|
|
188
|
+
const rawName = (name: string): string => name;
|
|
189
|
+
|
|
190
|
+
/** The JSON Schema for one output field, given its solved shape. */
|
|
191
|
+
function outputFieldSchema(shape: OutputShape): JsonSchema {
|
|
192
|
+
// A list output is always present (an empty array when nothing is produced),
|
|
193
|
+
// its elements never null: `OutputPathType[]` / `list[OutputPathType]`.
|
|
194
|
+
if (shape.kind === "list") {
|
|
195
|
+
return { type: "array", items: { type: "string", "x-styx-type": "path" } };
|
|
196
|
+
}
|
|
197
|
+
// A single output's key is always present on the Outputs object; when its gate
|
|
198
|
+
// is off the value is `null` (the backends type it `OutputPathType | None` /
|
|
199
|
+
// `OutputPathType | null`). So a gated single is a path OR null - not an
|
|
200
|
+
// absent key. We mark it `required` (see below) and carry the null branch.
|
|
201
|
+
if (shape.optional) return { type: ["string", "null"], "x-styx-type": "path" };
|
|
202
|
+
return { type: "string", "x-styx-type": "path" };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* JSON Schema for a tool's **Outputs object**: the set of files it produces
|
|
207
|
+
* (resolved outputs + mutable inputs surfaced as outputs) plus its captured
|
|
208
|
+
* stdout/stderr streams. Built from the same `collectOutputFields` /
|
|
209
|
+
* `streamFields` source of truth the Python and TypeScript backends use to type
|
|
210
|
+
* the Outputs dataclass/interface, so the three describe the same shape.
|
|
211
|
+
*
|
|
212
|
+
* Field encoding (mirrors how the language backends type each field):
|
|
213
|
+
* - required single -> `{ type: "string", x-styx-type: "path" }`
|
|
214
|
+
* - optional single -> `{ type: ["string", "null"], x-styx-type: "path" }`
|
|
215
|
+
* - list output -> `{ type: "array", items: { type: "string", x-styx-type: "path" } }`
|
|
216
|
+
* - stream field -> `{ type: "array", items: { type: "string" } }` (lines of
|
|
217
|
+
* text, NOT paths - the absent `x-styx-type` lets a consumer tell them apart)
|
|
218
|
+
*
|
|
219
|
+
* EVERY field is `required`: unlike the inputs schema (where an optional param
|
|
220
|
+
* key is genuinely omitted, so "not in `required`" is faithful), an Outputs
|
|
221
|
+
* field is always present - a gated single is `null`, a gated list is an empty
|
|
222
|
+
* array. So optionality is carried by the `null` type branch, and a strict
|
|
223
|
+
* validator accepts the actual emitted object (e.g. `{ "out_file": null }`).
|
|
224
|
+
*/
|
|
225
|
+
export function generateOutputsSchema(ctx: CodegenContext): JsonSchema {
|
|
226
|
+
const schema: JsonSchema = {
|
|
227
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
228
|
+
type: "object",
|
|
229
|
+
};
|
|
230
|
+
if (ctx.app?.doc?.title) schema.title = ctx.app.doc.title;
|
|
231
|
+
if (ctx.app?.doc?.description) schema.description = ctx.app.doc.description;
|
|
232
|
+
|
|
233
|
+
const properties: Record<string, JsonSchema> = {};
|
|
234
|
+
const required: string[] = [];
|
|
235
|
+
|
|
236
|
+
for (const field of collectOutputFields(ctx, rawName)) {
|
|
237
|
+
const prop = outputFieldSchema(field.shape);
|
|
238
|
+
if (field.doc) prop.description = field.doc;
|
|
239
|
+
properties[field.name] = prop;
|
|
240
|
+
required.push(field.name);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (const stream of streamFields(ctx, rawName)) {
|
|
244
|
+
const prop: JsonSchema = { type: "array", items: { type: "string" } };
|
|
245
|
+
if (stream.doc) prop.description = stream.doc;
|
|
246
|
+
properties[stream.name] = prop;
|
|
247
|
+
required.push(stream.name);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
schema.properties = properties;
|
|
251
|
+
if (required.length > 0) schema.required = required;
|
|
252
|
+
return schema;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Filesystem-safe, unique-within-the-package stem for one tool's schema files.
|
|
257
|
+
*
|
|
258
|
+
* In catalog mode every tool in a package emits into the same `<pkg>/` directory,
|
|
259
|
+
* so fixed `schema.json` names would clobber each other (only the last tool would
|
|
260
|
+
* survive). We prefix each tool's files with a slug derived from its app id -
|
|
261
|
+
* `snakeCase` so symbol-heavy ids like `3dfim+` / `@2dwarper.Allin` become safe,
|
|
262
|
+
* lowercase, collision-resistant stems (`3dfim` / `2dwarper_allin`) - and dedup
|
|
263
|
+
* through the shared package `scope` so the rare slug collision still gets a `_2`.
|
|
264
|
+
*
|
|
265
|
+
* Returns undefined when there is no scope (standalone single-tool emission, where
|
|
266
|
+
* one unprefixed `schema.json` is unambiguous) or no app id (anonymous descriptor).
|
|
267
|
+
*/
|
|
268
|
+
function schemaStem(ctx: CodegenContext, scope?: Scope): string | undefined {
|
|
269
|
+
const id = ctx.app?.id;
|
|
270
|
+
if (!id || !scope) return undefined;
|
|
271
|
+
return scope.add(snakeCase(id) || "schema");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export class JsonSchemaBackend implements Backend {
|
|
275
|
+
readonly name = "json-schema";
|
|
276
|
+
readonly target = "json-schema";
|
|
277
|
+
|
|
278
|
+
/** One scope per package so per-tool schema file stems stay unique in the suite directory. */
|
|
279
|
+
newPackageScope(): Scope {
|
|
280
|
+
return new Scope();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
emitApp(ctx: CodegenContext, scope?: Scope): EmittedApp {
|
|
284
|
+
const schema = generateSchema(ctx);
|
|
285
|
+
const outputsSchema = generateOutputsSchema(ctx);
|
|
286
|
+
// In a catalog build, prefix with a per-tool stem so co-located tools don't
|
|
287
|
+
// clobber one another; standalone single-tool builds keep the bare names.
|
|
288
|
+
const stem = schemaStem(ctx, scope);
|
|
289
|
+
const prefix = stem ? `${stem}.` : "";
|
|
290
|
+
return {
|
|
291
|
+
meta: ctx.app,
|
|
292
|
+
// Inputs and outputs are kept as two cleanly addressable artifacts
|
|
293
|
+
// (mirroring v1's `<tool>.input.json` / `<tool>.output.json` split): a
|
|
294
|
+
// consumer can fetch/compute either independently.
|
|
295
|
+
files: new Map([
|
|
296
|
+
[`${prefix}schema.json`, JSON.stringify(schema, null, 2)],
|
|
297
|
+
[`${prefix}outputs.schema.json`, JSON.stringify(outputsSchema, null, 2)],
|
|
298
|
+
]),
|
|
299
|
+
errors: [],
|
|
300
|
+
warnings: [],
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Symbol collision avoidance for code generation. */
|
|
2
|
+
export class Scope {
|
|
3
|
+
private readonly reserved: ReadonlySet<string>;
|
|
4
|
+
private readonly used: Set<string>;
|
|
5
|
+
private readonly parent?: Scope;
|
|
6
|
+
|
|
7
|
+
constructor(reserved: Iterable<string> = [], parent?: Scope) {
|
|
8
|
+
this.reserved = new Set(reserved);
|
|
9
|
+
this.used = new Set();
|
|
10
|
+
this.parent = parent;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Check if a symbol is already taken (in this scope or any parent). */
|
|
14
|
+
has(symbol: string): boolean {
|
|
15
|
+
return (
|
|
16
|
+
this.reserved.has(symbol) || this.used.has(symbol) || (this.parent?.has(symbol) ?? false)
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Add a symbol, appending a numeric suffix to avoid collisions. Returns the
|
|
22
|
+
* safe name.
|
|
23
|
+
*
|
|
24
|
+
* When a `recase` transform is given, a disambiguated candidate is routed back
|
|
25
|
+
* through it so the suffix is absorbed into the identifier's casing - e.g.
|
|
26
|
+
* `pascalCase` folds `Config_2` into `Config2` - rather than leaving a
|
|
27
|
+
* mixed-case `Config_2`. Uniqueness is always checked on the final emitted
|
|
28
|
+
* form, so two hints that case-collide still get distinct names. Defaults to
|
|
29
|
+
* identity (the bare `<name>_<n>` suffix) for callers that don't case-normalize.
|
|
30
|
+
*/
|
|
31
|
+
add(candidate: string, recase: (s: string) => string = (s) => s): string {
|
|
32
|
+
if (!this.has(candidate)) {
|
|
33
|
+
this.used.add(candidate);
|
|
34
|
+
return candidate;
|
|
35
|
+
}
|
|
36
|
+
let suffix = 2;
|
|
37
|
+
let safe = recase(`${candidate}_${suffix}`);
|
|
38
|
+
while (this.has(safe)) {
|
|
39
|
+
suffix++;
|
|
40
|
+
safe = recase(`${candidate}_${suffix}`);
|
|
41
|
+
}
|
|
42
|
+
this.used.add(safe);
|
|
43
|
+
return safe;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Create a child scope that inherits this scope's restrictions. */
|
|
47
|
+
child(reserved: Iterable<string> = []): Scope {
|
|
48
|
+
return new Scope(reserved, this);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { BoundType } from "../bindings/index.js";
|
|
2
|
+
import type { FieldInfo } from "./collect-field-info.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One entry of a kwarg-style signature, shared across language backends. Each
|
|
6
|
+
* entry describes a single user-facing parameter of the `_params()` factory
|
|
7
|
+
* and the kwarg wrapper:
|
|
8
|
+
* - `sigType`/`sigDefault` go directly into the function signature
|
|
9
|
+
* - `isOptional`/`hasExplicitDefault` drive the dict-build branches (required
|
|
10
|
+
* and explicitly-defaulted fields are always set; optional-no-default fields
|
|
11
|
+
* are conditionally set when not None/null)
|
|
12
|
+
* - `doc` is rendered into the per-param doc block (Args / @param)
|
|
13
|
+
*
|
|
14
|
+
* `name` is the scrubbed host identifier (used in the signature and function
|
|
15
|
+
* body); `wireKey` is the Boutiques field name (used as the dict key /
|
|
16
|
+
* TypedDict field key). They diverge when the wire key collides with a host
|
|
17
|
+
* reserved word, is not a valid identifier, or shadows a local in the emitting
|
|
18
|
+
* function (e.g. wire `float` -> host `float_`, wire `4d_input` -> host
|
|
19
|
+
* `v_4d_input`).
|
|
20
|
+
*/
|
|
21
|
+
export interface SigEntry {
|
|
22
|
+
/** Scrubbed host identifier - safe to use in signatures and as a local. */
|
|
23
|
+
name: string;
|
|
24
|
+
/** Boutiques field name - used as the dict key / TypedDict field key. */
|
|
25
|
+
wireKey: string;
|
|
26
|
+
sigType: string;
|
|
27
|
+
/** Rendered default expression in the host language, or undefined for required-no-default. */
|
|
28
|
+
sigDefault?: string;
|
|
29
|
+
/** True iff the BoundType is `optional` (i.e. dict-build conditionally includes). */
|
|
30
|
+
isOptional: boolean;
|
|
31
|
+
/** True iff the field carries an explicit defaultValue in its FieldInfo. */
|
|
32
|
+
hasExplicitDefault: boolean;
|
|
33
|
+
doc?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Per-backend rendering hooks for `buildSigEntries`. */
|
|
37
|
+
export interface SigOptions {
|
|
38
|
+
/** Render a non-optional BoundType as a language-native type expression. */
|
|
39
|
+
renderType: (t: BoundType) => string;
|
|
40
|
+
/** Suffix appended to `renderType(inner)` for optional fields (e.g. ` | None`, ` | null`). */
|
|
41
|
+
nullableSuffix: string;
|
|
42
|
+
/** Default expression for optional-no-default fields (e.g. `None`, `null`). */
|
|
43
|
+
nullableDefault: string;
|
|
44
|
+
/** Render a JS scalar default as a host-language literal (e.g. `True`/`true`). */
|
|
45
|
+
renderDefault: (v: string | number | boolean) => string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build per-field signature entries for the kwarg wrapper and params factory.
|
|
50
|
+
* Skips `@type` (the factory injects it as a constant). Required-no-default
|
|
51
|
+
* entries are placed before defaulted ones so the resulting signature is
|
|
52
|
+
* syntactically valid in both Python and TS.
|
|
53
|
+
*
|
|
54
|
+
* `registerLocal` is called once per field with the wire key; it must return
|
|
55
|
+
* a scrubbed, unique host identifier (typically by combining a language-aware
|
|
56
|
+
* scrub function with `Scope.add()`). The caller's scope should already have
|
|
57
|
+
* the function's other locals (`params`, `runner`, ...) reserved so this
|
|
58
|
+
* registration cannot collide with them.
|
|
59
|
+
*/
|
|
60
|
+
export function buildSigEntries(
|
|
61
|
+
rootType: Extract<BoundType, { kind: "struct" }>,
|
|
62
|
+
fieldInfo: Map<string, FieldInfo>,
|
|
63
|
+
registerLocal: (wireKey: string) => string,
|
|
64
|
+
opts: SigOptions,
|
|
65
|
+
): SigEntry[] {
|
|
66
|
+
const entries: SigEntry[] = [];
|
|
67
|
+
for (const [fieldName, fieldType] of Object.entries(rootType.fields)) {
|
|
68
|
+
if (fieldType.kind === "literal") continue;
|
|
69
|
+
|
|
70
|
+
const fi = fieldInfo.get(fieldName);
|
|
71
|
+
const isOptional = fieldType.kind === "optional";
|
|
72
|
+
const inner = isOptional ? fieldType.inner : fieldType;
|
|
73
|
+
let sigType = opts.renderType(inner);
|
|
74
|
+
if (isOptional) sigType += opts.nullableSuffix;
|
|
75
|
+
|
|
76
|
+
let sigDefault: string | undefined;
|
|
77
|
+
const hasExplicitDefault = fi?.defaultValue !== undefined;
|
|
78
|
+
if (hasExplicitDefault) {
|
|
79
|
+
sigDefault = opts.renderDefault(fi!.defaultValue!);
|
|
80
|
+
} else if (isOptional) {
|
|
81
|
+
sigDefault = opts.nullableDefault;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
entries.push({
|
|
85
|
+
name: registerLocal(fieldName),
|
|
86
|
+
wireKey: fieldName,
|
|
87
|
+
sigType,
|
|
88
|
+
sigDefault,
|
|
89
|
+
isOptional,
|
|
90
|
+
hasExplicitDefault,
|
|
91
|
+
doc: fi?.doc,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const required = entries.filter((e) => e.sigDefault === undefined);
|
|
95
|
+
const defaulted = entries.filter((e) => e.sigDefault !== undefined);
|
|
96
|
+
return [...required, ...defaulted];
|
|
97
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { BoundType } from "../bindings/index.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-language rendering hooks for the call-site snippet renderer. The recursive
|
|
5
|
+
* structure (struct -> object literal, union -> picked variant, list -> array)
|
|
6
|
+
* is language-agnostic; only leaf-literal syntax, object keys, and indentation
|
|
7
|
+
* differ, and those are supplied here.
|
|
8
|
+
*/
|
|
9
|
+
export interface SnippetDialect {
|
|
10
|
+
/** One indentation level (e.g. `" "` for Python, `" "` for TypeScript). */
|
|
11
|
+
indentUnit: string;
|
|
12
|
+
/** Render a string value as a host literal (quoted). */
|
|
13
|
+
string(value: string): string;
|
|
14
|
+
/** Render a boolean value as a host literal (`True`/`true`). */
|
|
15
|
+
boolean(value: boolean): string;
|
|
16
|
+
/** Render a number value as a host literal. */
|
|
17
|
+
number(value: number): string;
|
|
18
|
+
/** Host literal for a null/None value. */
|
|
19
|
+
null: string;
|
|
20
|
+
/** Render an object-literal key from a wire key, quoting when not a bare identifier. */
|
|
21
|
+
objKey(wireKey: string): string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Options shared by both language renderers. */
|
|
25
|
+
export interface SnippetOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Module the package is imported from (e.g. `"niwrap"`). Defaults to the
|
|
28
|
+
* project name on the context. When unset and no project name is available,
|
|
29
|
+
* Python falls back to a bare `import <pkg>` and TypeScript omits the import.
|
|
30
|
+
*/
|
|
31
|
+
packageRoot?: string;
|
|
32
|
+
/** Whether to prepend an import line. Defaults to `true`. */
|
|
33
|
+
includeImport?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Is `value` a plain (non-array) object usable as a struct/union config? */
|
|
37
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Strip transparent `optional` wrappers down to the underlying value type. */
|
|
42
|
+
function unwrap(type: BoundType): BoundType {
|
|
43
|
+
return type.kind === "optional" ? unwrap(type.inner) : type;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Render a leaf primitive (string/number/bool/null) by its JS runtime type. */
|
|
47
|
+
function renderPrimitive(value: unknown, d: SnippetDialect): string {
|
|
48
|
+
if (value === null || value === undefined) return d.null;
|
|
49
|
+
switch (typeof value) {
|
|
50
|
+
case "string":
|
|
51
|
+
return d.string(value);
|
|
52
|
+
case "boolean":
|
|
53
|
+
return d.boolean(value);
|
|
54
|
+
case "number":
|
|
55
|
+
return d.number(value);
|
|
56
|
+
default:
|
|
57
|
+
// Bigint / unexpected: fall back to a quoted string form.
|
|
58
|
+
return d.string(String(value));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The `@type` discriminator value carried by a struct type, if any. */
|
|
63
|
+
function structAtType(type: Extract<BoundType, { kind: "struct" }>): string | undefined {
|
|
64
|
+
const field = type.fields["@type"];
|
|
65
|
+
return field && field.kind === "literal" ? String(field.value) : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render a struct config as a host object literal (Python dict / TS object).
|
|
70
|
+
* Keys are the Boutiques wire names (the generated TypedDict / interface keys) -
|
|
71
|
+
* nested structs have no constructor function in the generated code, so callers
|
|
72
|
+
* build them as plain object literals.
|
|
73
|
+
*
|
|
74
|
+
* `@type` is emitted from the struct's literal discriminator field when present
|
|
75
|
+
* (union variants carry a required, load-bearing `@type`); for the root call the
|
|
76
|
+
* tag is injected via `injectAtType` (the root's `@type` is derived from
|
|
77
|
+
* `pkg/appId`, not stored as a field). Non-`@type` literal fields have no
|
|
78
|
+
* runtime representation and are skipped.
|
|
79
|
+
*/
|
|
80
|
+
export function renderStructLiteral(
|
|
81
|
+
value: unknown,
|
|
82
|
+
type: Extract<BoundType, { kind: "struct" }>,
|
|
83
|
+
indent: string,
|
|
84
|
+
d: SnippetDialect,
|
|
85
|
+
injectAtType?: string,
|
|
86
|
+
): string {
|
|
87
|
+
const obj = isRecord(value) ? value : {};
|
|
88
|
+
const inner = indent + d.indentUnit;
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
|
|
91
|
+
const atType = structAtType(type) ?? injectAtType;
|
|
92
|
+
if (atType !== undefined) {
|
|
93
|
+
lines.push(`${inner}${d.objKey("@type")}: ${d.string(atType)},`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const [wireKey, fieldType] of Object.entries(type.fields)) {
|
|
97
|
+
if (wireKey === "@type") continue;
|
|
98
|
+
if (fieldType.kind === "literal") continue; // no runtime representation
|
|
99
|
+
if (!(wireKey in obj)) continue; // omitted optional / absent field
|
|
100
|
+
lines.push(`${inner}${d.objKey(wireKey)}: ${renderValue(obj[wireKey], fieldType, inner, d)},`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (lines.length === 0) return "{}";
|
|
104
|
+
return `{\n${lines.join("\n")}\n${indent}}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Render a union config. An object value with an `@type` is matched to the
|
|
109
|
+
* struct variant whose discriminator equals that tag and rendered as that
|
|
110
|
+
* variant's object literal; a primitive value (a bare literal/scalar variant,
|
|
111
|
+
* e.g. a mixed union's `"Linear"` const arm) is rendered directly.
|
|
112
|
+
*/
|
|
113
|
+
function renderUnion(
|
|
114
|
+
value: unknown,
|
|
115
|
+
type: Extract<BoundType, { kind: "union" }>,
|
|
116
|
+
indent: string,
|
|
117
|
+
d: SnippetDialect,
|
|
118
|
+
): string {
|
|
119
|
+
if (isRecord(value)) {
|
|
120
|
+
const tag = value["@type"];
|
|
121
|
+
const match = type.variants.find(
|
|
122
|
+
(v) => v.type.kind === "struct" && structAtType(v.type) === String(tag),
|
|
123
|
+
);
|
|
124
|
+
if (match && match.type.kind === "struct") {
|
|
125
|
+
return renderStructLiteral(value, match.type, indent, d);
|
|
126
|
+
}
|
|
127
|
+
// Unknown/missing tag: fall back to the sole struct variant if there is one,
|
|
128
|
+
// otherwise emit an empty object. (Well-formed configs always match.)
|
|
129
|
+
const onlyStruct = type.variants.filter((v) => v.type.kind === "struct");
|
|
130
|
+
if (onlyStruct.length === 1 && onlyStruct[0]!.type.kind === "struct") {
|
|
131
|
+
return renderStructLiteral(value, onlyStruct[0]!.type, indent, d);
|
|
132
|
+
}
|
|
133
|
+
return "{}";
|
|
134
|
+
}
|
|
135
|
+
return renderPrimitive(value, d);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Render a list config. A list of structs/unions renders multi-line (one object
|
|
140
|
+
* literal per element); a list of primitives renders inline (`[1, 2, 3]`).
|
|
141
|
+
*/
|
|
142
|
+
function renderList(value: unknown, item: BoundType, indent: string, d: SnippetDialect): string {
|
|
143
|
+
if (!Array.isArray(value) || value.length === 0) return "[]";
|
|
144
|
+
const it = unwrap(item);
|
|
145
|
+
if (it.kind === "struct" || it.kind === "union") {
|
|
146
|
+
const inner = indent + d.indentUnit;
|
|
147
|
+
const elems = value.map((el) => `${inner}${renderValue(el, it, inner, d)},`);
|
|
148
|
+
return `[\n${elems.join("\n")}\n${indent}]`;
|
|
149
|
+
}
|
|
150
|
+
return `[${value.map((el) => renderValue(el, it, indent, d)).join(", ")}]`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Render a config value to a host-language expression, guided by its BoundType.
|
|
155
|
+
*
|
|
156
|
+
* `indent` is the leading whitespace of the line the value starts on; multi-line
|
|
157
|
+
* forms (object/array literals) place their members one level deeper and close
|
|
158
|
+
* back at `indent`. The renderer follows the BoundType tree for shape and reads
|
|
159
|
+
* the parallel config object for values, so unknown / absent keys are simply
|
|
160
|
+
* omitted (a partial config produces a partial snippet).
|
|
161
|
+
*/
|
|
162
|
+
export function renderValue(
|
|
163
|
+
value: unknown,
|
|
164
|
+
type: BoundType,
|
|
165
|
+
indent: string,
|
|
166
|
+
d: SnippetDialect,
|
|
167
|
+
): string {
|
|
168
|
+
// A present null/undefined renders as the host null literal regardless of the
|
|
169
|
+
// declared type. An explicitly-unset optional field (a form may emit `null`
|
|
170
|
+
// rather than omitting the key) must not be coerced into an empty `{}` / `[]`
|
|
171
|
+
// by the struct/list branches below - that would be a value the generated code
|
|
172
|
+
// rejects (a struct missing its required keys, a non-array for a list).
|
|
173
|
+
if (value === null || value === undefined) return d.null;
|
|
174
|
+
const t = unwrap(type);
|
|
175
|
+
switch (t.kind) {
|
|
176
|
+
case "struct":
|
|
177
|
+
return renderStructLiteral(value, t, indent, d);
|
|
178
|
+
case "union":
|
|
179
|
+
return renderUnion(value, t, indent, d);
|
|
180
|
+
case "list":
|
|
181
|
+
return renderList(value, t.item, indent, d);
|
|
182
|
+
default:
|
|
183
|
+
return renderPrimitive(value, d);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Split a string into lowercase word tokens for case conversion. */
|
|
2
|
+
function tokenize(s: string): string[] {
|
|
3
|
+
return s
|
|
4
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2") // camelCase boundary
|
|
5
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") // consecutive uppercase -> keep runs
|
|
6
|
+
.replace(/[^a-zA-Z0-9]+/g, " ")
|
|
7
|
+
.trim()
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.split(/\s+/)
|
|
10
|
+
.filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function snakeCase(s: string): string {
|
|
14
|
+
return tokenize(s).join("_");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function screamingSnakeCase(s: string): string {
|
|
18
|
+
return tokenize(s).join("_").toUpperCase();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function pascalCase(s: string): string {
|
|
22
|
+
return tokenize(s)
|
|
23
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
24
|
+
.join("");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function camelCase(s: string): string {
|
|
28
|
+
const pascal = pascalCase(s);
|
|
29
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
30
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime version floors baked into generated dependency metadata.
|
|
3
|
+
*
|
|
4
|
+
* styx2-generated code calls `mutable_copy` / `mutableCopy` (introduced in the
|
|
5
|
+
* styxdefs 0.7.0 / styxdefs-js 0.2.0 release), so emitted packages genuinely
|
|
6
|
+
* require that runtime floor. This is the single source of truth: bump here and
|
|
7
|
+
* both the Python and TypeScript backends pick it up.
|
|
8
|
+
*/
|
|
9
|
+
export const STYXDEFS_COMPAT = {
|
|
10
|
+
/** PEP 508 specifier for the Python `styxdefs` package. */
|
|
11
|
+
python: ">=0.7.0,<0.8.0",
|
|
12
|
+
/** npm semver range for the `styxdefs` package. */
|
|
13
|
+
npm: "^0.2.0",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extra Python runtime packages the root distribution pulls in (container +
|
|
18
|
+
* graph runners). Left unpinned - styxdefs's floor constrains them transitively
|
|
19
|
+
* via their own inter-package pins.
|
|
20
|
+
*/
|
|
21
|
+
export const PYTHON_RUNNER_DEPS = ["styxdocker", "styxsingularity", "styxgraph"] as const;
|