@tagma/sdk 0.6.7 → 0.6.8

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/src/sdk.ts CHANGED
@@ -127,8 +127,17 @@ export {
127
127
  extractInputReferences,
128
128
  resolveTaskInputs,
129
129
  extractTaskOutputs,
130
+ inferPromptPorts,
131
+ } from './ports';
132
+ export type {
133
+ SubstituteResult,
134
+ InputResolution,
135
+ ExtractResult,
136
+ PromptPortInference,
137
+ PromptPortConflict,
138
+ PromptUpstreamNeighbor,
139
+ PromptDownstreamNeighbor,
130
140
  } from './ports';
131
- export type { SubstituteResult, InputResolution, ExtractResult } from './ports';
132
141
 
133
142
  // ── All types from @tagma/types + runtime constants ──
134
143
  export * from './types';
@@ -2,7 +2,19 @@ import { describe, expect, test } from 'bun:test';
2
2
  import { validateRaw } from './validate-raw';
3
3
  import type { RawPipelineConfig, RawTaskConfig, RawTrackConfig, TaskPorts } from './types';
4
4
 
5
- function task(overrides: Partial<RawTaskConfig> & { id: string }): RawTaskConfig {
5
+ // ─── Helpers ─────────────────────────────────────────────────────────
6
+ //
7
+ // Prompt Tasks no longer declare ports — the validator errors out when
8
+ // they try. The structural port tests below therefore use Command Tasks
9
+ // by default (where declared ports remain the source of truth) and
10
+ // switch to Prompt Tasks only for the "must not declare ports" and the
11
+ // inferred-port cross-checks.
12
+
13
+ function commandTask(overrides: Partial<RawTaskConfig> & { id: string }): RawTaskConfig {
14
+ return { command: 'echo hi', ...overrides };
15
+ }
16
+
17
+ function promptTask(overrides: Partial<RawTaskConfig> & { id: string }): RawTaskConfig {
6
18
  return { prompt: 'do a thing', ...overrides };
7
19
  }
8
20
 
@@ -16,9 +28,9 @@ function errorsFor(taskConfig: RawTaskConfig): ReturnType<typeof validateRaw> {
16
28
  }
17
29
 
18
30
  /**
19
- * Return only the errors whose path points inside the given task's
20
- * `.ports` subtree, so assertions don't pick up unrelated cycle or
21
- * name-validation errors that the rest of validate-raw emits.
31
+ * Return only errors whose path points inside the given task's `.ports`
32
+ * subtree. Keeps assertions focused unrelated cycle / name-validation
33
+ * errors don't pollute the match set.
22
34
  */
23
35
  function portsErrors(errors: ReturnType<typeof validateRaw>): typeof errors {
24
36
  return errors.filter(
@@ -26,17 +38,17 @@ function portsErrors(errors: ReturnType<typeof validateRaw>): typeof errors {
26
38
  );
27
39
  }
28
40
 
29
- // ─── Structural validation ───────────────────────────────────────────
41
+ // ─── Structural validation (Command Tasks) ───────────────────────────
30
42
 
31
- describe('validateRaw — port structure', () => {
43
+ describe('validateRaw — port structure (command tasks)', () => {
32
44
  test('empty ports object is accepted (no-op)', () => {
33
- const errors = errorsFor(task({ id: 'a', ports: {} }));
45
+ const errors = errorsFor(commandTask({ id: 'a', ports: {} }));
34
46
  expect(portsErrors(errors)).toEqual([]);
35
47
  });
36
48
 
37
49
  test('rejects non-array ports.inputs', () => {
38
50
  const ports = { inputs: 'not-an-array' as unknown as [] } as TaskPorts;
39
- const errors = errorsFor(task({ id: 'a', ports }));
51
+ const errors = errorsFor(commandTask({ id: 'a', ports }));
40
52
  const e = portsErrors(errors);
41
53
  expect(e.length).toBeGreaterThan(0);
42
54
  expect(e[0]!.message).toMatch(/must be an array/);
@@ -44,13 +56,13 @@ describe('validateRaw — port structure', () => {
44
56
 
45
57
  test('rejects non-object port entry', () => {
46
58
  const ports = { inputs: ['not-an-object' as unknown as never] } as TaskPorts;
47
- const errors = errorsFor(task({ id: 'a', ports }));
59
+ const errors = errorsFor(commandTask({ id: 'a', ports }));
48
60
  expect(portsErrors(errors).some((e) => /must be an object/.test(e.message))).toBe(true);
49
61
  });
50
62
 
51
63
  test('requires port.name to be a non-empty string', () => {
52
64
  const ports: TaskPorts = { inputs: [{ name: '', type: 'string' }] };
53
- const errors = errorsFor(task({ id: 'a', ports }));
65
+ const errors = errorsFor(commandTask({ id: 'a', ports }));
54
66
  expect(portsErrors(errors).some((e) => /port\.name is required/.test(e.message))).toBe(true);
55
67
  });
56
68
 
@@ -62,7 +74,7 @@ describe('validateRaw — port structure', () => {
62
74
  { name: 'has.dot', type: 'string' },
63
75
  ],
64
76
  };
65
- const errors = errorsFor(task({ id: 'a', ports }));
77
+ const errors = errorsFor(commandTask({ id: 'a', ports }));
66
78
  const msgs = portsErrors(errors).map((e) => e.message);
67
79
  expect(msgs.filter((m) => /port name .* is invalid/.test(m)).length).toBe(3);
68
80
  });
@@ -74,7 +86,7 @@ describe('validateRaw — port structure', () => {
74
86
  { name: 'x', type: 'number' },
75
87
  ],
76
88
  };
77
- const errors = errorsFor(task({ id: 'a', ports }));
89
+ const errors = errorsFor(commandTask({ id: 'a', ports }));
78
90
  expect(portsErrors(errors).some((e) => /Duplicate ports\.inputs name/.test(e.message))).toBe(
79
91
  true,
80
92
  );
@@ -82,13 +94,13 @@ describe('validateRaw — port structure', () => {
82
94
 
83
95
  test('rejects unknown port type', () => {
84
96
  const ports = { inputs: [{ name: 'x', type: 'made-up' as never }] } as TaskPorts;
85
- const errors = errorsFor(task({ id: 'a', ports }));
97
+ const errors = errorsFor(commandTask({ id: 'a', ports }));
86
98
  expect(portsErrors(errors).some((e) => /type must be one of/.test(e.message))).toBe(true);
87
99
  });
88
100
 
89
101
  test('enum port requires a non-empty enum array', () => {
90
102
  const ports: TaskPorts = { inputs: [{ name: 'x', type: 'enum' }] };
91
- const errors = errorsFor(task({ id: 'a', ports }));
103
+ const errors = errorsFor(commandTask({ id: 'a', ports }));
92
104
  expect(portsErrors(errors).some((e) => /non-empty "enum"/.test(e.message))).toBe(true);
93
105
  });
94
106
 
@@ -96,7 +108,7 @@ describe('validateRaw — port structure', () => {
96
108
  const ports = {
97
109
  inputs: [{ name: 'x', type: 'enum' as const, enum: ['a', 1 as unknown as string] }],
98
110
  } as TaskPorts;
99
- const errors = errorsFor(task({ id: 'a', ports }));
111
+ const errors = errorsFor(commandTask({ id: 'a', ports }));
100
112
  expect(portsErrors(errors).some((e) => /enum values must all be strings/.test(e.message))).toBe(
101
113
  true,
102
114
  );
@@ -104,23 +116,21 @@ describe('validateRaw — port structure', () => {
104
116
 
105
117
  test('`from` must be a string', () => {
106
118
  const ports = {
107
- inputs: [
108
- { name: 'x', type: 'string' as const, from: 42 as unknown as string },
109
- ],
119
+ inputs: [{ name: 'x', type: 'string' as const, from: 42 as unknown as string }],
110
120
  } as TaskPorts;
111
- const errors = errorsFor(task({ id: 'a', ports }));
121
+ const errors = errorsFor(commandTask({ id: 'a', ports }));
112
122
  expect(portsErrors(errors).some((e) => /"from" must be a string/.test(e.message))).toBe(true);
113
123
  });
114
124
  });
115
125
 
116
- // ─── Input/output separation ─────────────────────────────────────────
126
+ // ─── Input/output separation (Command Tasks) ─────────────────────────
117
127
 
118
- describe('validateRaw — input vs output constraints', () => {
128
+ describe('validateRaw — input vs output constraints (command tasks)', () => {
119
129
  test('`required` on an output emits a warning (not an error)', () => {
120
130
  const ports: TaskPorts = {
121
131
  outputs: [{ name: 'x', type: 'string', required: true }],
122
132
  };
123
- const errors = errorsFor(task({ id: 'a', ports, prompt: 'x' }));
133
+ const errors = errorsFor(commandTask({ id: 'a', ports }));
124
134
  const portErrs = portsErrors(errors);
125
135
  expect(portErrs.length).toBeGreaterThan(0);
126
136
  expect(portErrs[0]!.severity).toBe('warning');
@@ -131,44 +141,60 @@ describe('validateRaw — input vs output constraints', () => {
131
141
  const ports: TaskPorts = {
132
142
  outputs: [{ name: 'x', type: 'string', from: 'whatever' }],
133
143
  };
134
- const errors = errorsFor(task({ id: 'a', ports }));
144
+ const errors = errorsFor(commandTask({ id: 'a', ports }));
135
145
  const portErrs = portsErrors(errors);
136
146
  expect(portErrs[0]!.severity).toBe('warning');
137
147
  });
138
148
  });
139
149
 
140
- // ─── {{inputs.X}} cross-check ────────────────────────────────────────
150
+ // ─── Prompt Tasks must not declare ports ────────────────────────────
141
151
 
142
- describe('validateRaw — placeholder cross-check', () => {
143
- test('references to undeclared inputs in prompt are errors', () => {
152
+ describe('validateRaw — prompt tasks reject declared ports', () => {
153
+ test('declaring any ports on a prompt task is an error', () => {
144
154
  const errors = errorsFor(
145
- task({
155
+ promptTask({
146
156
  id: 'a',
147
- prompt: 'city={{inputs.city}} id={{inputs.id}}',
148
- ports: { inputs: [{ name: 'city', type: 'string' }] },
157
+ ports: { inputs: [{ name: 'x', type: 'string' }] },
149
158
  }),
150
159
  );
151
- const msgs = errors.map((e) => e.message);
152
- expect(msgs.some((m) => m.includes('references "{{inputs.id}}"'))).toBe(true);
153
- expect(msgs.some((m) => m.includes('references "{{inputs.city}}"'))).toBe(false);
160
+ const msg = errors.find((e) => /do not declare ports/.test(e.message));
161
+ expect(msg).toBeDefined();
162
+ expect(msg!.path).toBe('tracks[0].tasks[0].ports');
163
+ });
164
+
165
+ test('empty ports object still triggers the error (design is "no ports field at all")', () => {
166
+ // An empty `ports: {}` is a common state after the user deletes every
167
+ // port without clearing the outer key — we still flag it so the editor
168
+ // can offer a "remove ports field" fix-up.
169
+ const errors = errorsFor(promptTask({ id: 'a', ports: {} }));
170
+ expect(errors.some((e) => /do not declare ports/.test(e.message))).toBe(true);
154
171
  });
155
172
 
156
- test('references to undeclared inputs in command are errors', () => {
173
+ test('command tasks with ports are unaffected', () => {
157
174
  const errors = errorsFor(
158
- task({
175
+ commandTask({ id: 'a', ports: { outputs: [{ name: 'x', type: 'string' }] } }),
176
+ );
177
+ expect(errors.some((e) => /do not declare ports/.test(e.message))).toBe(false);
178
+ });
179
+ });
180
+
181
+ // ─── {{inputs.X}} cross-check ────────────────────────────────────────
182
+
183
+ describe('validateRaw — placeholder cross-check', () => {
184
+ test('command task: reference to undeclared input is an error', () => {
185
+ const errors = errorsFor(
186
+ commandTask({
159
187
  id: 'a',
160
- prompt: undefined,
161
188
  command: 'echo {{inputs.oops}}',
162
189
  }),
163
190
  );
164
191
  expect(errors.some((e) => e.message.includes('references "{{inputs.oops}}"'))).toBe(true);
165
192
  });
166
193
 
167
- test('declared inputs with no references emit a warning for command tasks', () => {
194
+ test('command task: declared-but-unreferenced input emits a warning', () => {
168
195
  const errors = errorsFor(
169
- task({
196
+ commandTask({
170
197
  id: 'a',
171
- prompt: undefined,
172
198
  command: 'echo hi',
173
199
  ports: { inputs: [{ name: 'unused', type: 'string' }] },
174
200
  }),
@@ -179,20 +205,179 @@ describe('validateRaw — placeholder cross-check', () => {
179
205
  expect(warnings.length).toBe(1);
180
206
  });
181
207
 
182
- test('declared inputs with no references do NOT warn for prompt tasks', () => {
183
- // Prompt tasks consume inputs through the auto-injected [Inputs]
184
- // context block, so "unused" is a false alarm for them. Only command
185
- // tasks should see the unused-input warning.
208
+ test('prompt task: {{inputs.X}} must reference an upstream Command output', () => {
209
+ // Prompt `down` references `{{inputs.city}}` and `{{inputs.id}}`. The
210
+ // upstream Command `up` exports `city` but not `id`, so only `id`
211
+ // should be flagged.
212
+ const config: RawPipelineConfig = {
213
+ name: 'p',
214
+ tracks: [
215
+ {
216
+ id: 't',
217
+ name: 't',
218
+ tasks: [
219
+ {
220
+ id: 'up',
221
+ command: 'echo stub',
222
+ ports: { outputs: [{ name: 'city', type: 'string' }] },
223
+ },
224
+ {
225
+ id: 'down',
226
+ depends_on: ['up'],
227
+ prompt: 'city={{inputs.city}} id={{inputs.id}}',
228
+ },
229
+ ],
230
+ },
231
+ ],
232
+ };
233
+ const errors = validateRaw(config);
234
+ const msgs = errors.map((e) => e.message);
235
+ expect(msgs.some((m) => m.includes('references "{{inputs.id}}"'))).toBe(true);
236
+ expect(msgs.some((m) => m.includes('references "{{inputs.city}}"'))).toBe(false);
237
+ });
238
+
239
+ test('prompt task: references without an upstream Command produce errors', () => {
240
+ // Prompt with no upstream Command at all — every reference is
241
+ // unresolvable because there's nothing to infer inputs from.
186
242
  const errors = errorsFor(
187
- task({
243
+ promptTask({
188
244
  id: 'a',
189
- prompt: 'do the thing',
190
- ports: { inputs: [{ name: 'context', type: 'string' }] },
245
+ prompt: 'hi {{inputs.missing}}',
191
246
  }),
192
247
  );
193
- const warnings = errors.filter(
194
- (e) => e.severity === 'warning' && /declared input is unused/.test(e.message),
195
- );
196
- expect(warnings.length).toBe(0);
248
+ expect(errors.some((e) => e.message.includes('references "{{inputs.missing}}"'))).toBe(true);
249
+ });
250
+
251
+ test('prompt task: upstream Prompt neighbor contributes nothing (free-text only)', () => {
252
+ // `up` is a Prompt (not Command) — its declared ports would be an
253
+ // error anyway, but even if the user somehow declared outputs on it,
254
+ // a downstream Prompt cannot reference them via {{inputs.X}}.
255
+ const config: RawPipelineConfig = {
256
+ name: 'p',
257
+ tracks: [
258
+ {
259
+ id: 't',
260
+ name: 't',
261
+ tasks: [
262
+ { id: 'up', prompt: 'pick a city' },
263
+ {
264
+ id: 'down',
265
+ depends_on: ['up'],
266
+ prompt: 'greet {{inputs.city}}',
267
+ },
268
+ ],
269
+ },
270
+ ],
271
+ };
272
+ const errors = validateRaw(config);
273
+ expect(errors.some((e) => e.message.includes('references "{{inputs.city}}"'))).toBe(true);
274
+ });
275
+ });
276
+
277
+ // ─── Inferred-port conflict detection (Prompt Tasks) ─────────────────
278
+
279
+ describe('validateRaw — prompt inferred-port conflicts', () => {
280
+ test('two upstream Commands exporting the same name → error', () => {
281
+ const config: RawPipelineConfig = {
282
+ name: 'p',
283
+ tracks: [
284
+ {
285
+ id: 't',
286
+ name: 't',
287
+ tasks: [
288
+ {
289
+ id: 'a',
290
+ command: 'echo a',
291
+ ports: { outputs: [{ name: 'city', type: 'string' }] },
292
+ },
293
+ {
294
+ id: 'b',
295
+ command: 'echo b',
296
+ ports: { outputs: [{ name: 'city', type: 'string' }] },
297
+ },
298
+ {
299
+ id: 'down',
300
+ depends_on: ['a', 'b'],
301
+ prompt: 'city={{inputs.city}}',
302
+ },
303
+ ],
304
+ },
305
+ ],
306
+ };
307
+ const errors = validateRaw(config);
308
+ expect(
309
+ errors.some(
310
+ (e) =>
311
+ /cannot disambiguate/.test(e.message) &&
312
+ e.message.includes('t.a') &&
313
+ e.message.includes('t.b'),
314
+ ),
315
+ ).toBe(true);
316
+ });
317
+
318
+ test('two downstream Commands with incompatible input types → error', () => {
319
+ const config: RawPipelineConfig = {
320
+ name: 'p',
321
+ tracks: [
322
+ {
323
+ id: 't',
324
+ name: 't',
325
+ tasks: [
326
+ { id: 'middle', prompt: 'produce date' },
327
+ {
328
+ id: 'd1',
329
+ depends_on: ['middle'],
330
+ command: 'echo {{inputs.date}}',
331
+ ports: { inputs: [{ name: 'date', type: 'string', required: true }] },
332
+ },
333
+ {
334
+ id: 'd2',
335
+ depends_on: ['middle'],
336
+ command: 'echo {{inputs.date}}',
337
+ ports: { inputs: [{ name: 'date', type: 'number', required: true }] },
338
+ },
339
+ ],
340
+ },
341
+ ],
342
+ };
343
+ const errors = validateRaw(config);
344
+ expect(
345
+ errors.some(
346
+ (e) =>
347
+ /disagree on the shape of inferred output "date"/.test(e.message) &&
348
+ e.path === 'tracks[0].tasks[0]',
349
+ ),
350
+ ).toBe(true);
351
+ });
352
+
353
+ test('two downstream Commands with matching input types → no conflict', () => {
354
+ const config: RawPipelineConfig = {
355
+ name: 'p',
356
+ tracks: [
357
+ {
358
+ id: 't',
359
+ name: 't',
360
+ tasks: [
361
+ { id: 'middle', prompt: 'produce date' },
362
+ {
363
+ id: 'd1',
364
+ depends_on: ['middle'],
365
+ command: 'echo {{inputs.date}}',
366
+ ports: { inputs: [{ name: 'date', type: 'string', required: true }] },
367
+ },
368
+ {
369
+ id: 'd2',
370
+ depends_on: ['middle'],
371
+ command: 'echo {{inputs.date}}',
372
+ ports: { inputs: [{ name: 'date', type: 'string', required: false }] },
373
+ },
374
+ ],
375
+ },
376
+ ],
377
+ };
378
+ const errors = validateRaw(config);
379
+ // Error list should contain no "disagree on the shape" entry — the
380
+ // two inputs agree on type and (no enum), so they merge.
381
+ expect(errors.some((e) => /disagree on the shape/.test(e.message))).toBe(false);
197
382
  });
198
383
  });