@tagma/sdk 0.6.3 → 0.6.5

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 (73) hide show
  1. package/README.md +8 -5
  2. package/dist/dag.test.d.ts +2 -0
  3. package/dist/dag.test.d.ts.map +1 -0
  4. package/dist/dag.test.js +42 -0
  5. package/dist/dag.test.js.map +1 -0
  6. package/dist/engine-ports.test.d.ts +2 -0
  7. package/dist/engine-ports.test.d.ts.map +1 -0
  8. package/dist/engine-ports.test.js +378 -0
  9. package/dist/engine-ports.test.js.map +1 -0
  10. package/dist/engine.d.ts.map +1 -1
  11. package/dist/engine.js +194 -21
  12. package/dist/engine.js.map +1 -1
  13. package/dist/pipeline-runner.d.ts.map +1 -1
  14. package/dist/pipeline-runner.js +3 -0
  15. package/dist/pipeline-runner.js.map +1 -1
  16. package/dist/ports.d.ts +118 -0
  17. package/dist/ports.d.ts.map +1 -0
  18. package/dist/ports.js +365 -0
  19. package/dist/ports.js.map +1 -0
  20. package/dist/ports.test.d.ts +2 -0
  21. package/dist/ports.test.d.ts.map +1 -0
  22. package/dist/ports.test.js +262 -0
  23. package/dist/ports.test.js.map +1 -0
  24. package/dist/prompt-doc.d.ts +35 -1
  25. package/dist/prompt-doc.d.ts.map +1 -1
  26. package/dist/prompt-doc.js +110 -0
  27. package/dist/prompt-doc.js.map +1 -1
  28. package/dist/prompt-doc.test.d.ts +2 -0
  29. package/dist/prompt-doc.test.d.ts.map +1 -0
  30. package/dist/prompt-doc.test.js +145 -0
  31. package/dist/prompt-doc.test.js.map +1 -0
  32. package/dist/runner.d.ts +17 -0
  33. package/dist/runner.d.ts.map +1 -1
  34. package/dist/runner.js +171 -8
  35. package/dist/runner.js.map +1 -1
  36. package/dist/runner.test.d.ts +2 -0
  37. package/dist/runner.test.d.ts.map +1 -0
  38. package/dist/runner.test.js +119 -0
  39. package/dist/runner.test.js.map +1 -0
  40. package/dist/schema-ports.test.d.ts +2 -0
  41. package/dist/schema-ports.test.d.ts.map +1 -0
  42. package/dist/schema-ports.test.js +219 -0
  43. package/dist/schema-ports.test.js.map +1 -0
  44. package/dist/schema.d.ts.map +1 -1
  45. package/dist/schema.js +8 -0
  46. package/dist/schema.js.map +1 -1
  47. package/dist/sdk.d.ts +3 -1
  48. package/dist/sdk.d.ts.map +1 -1
  49. package/dist/sdk.js +5 -1
  50. package/dist/sdk.js.map +1 -1
  51. package/dist/validate-raw-ports.test.d.ts +2 -0
  52. package/dist/validate-raw-ports.test.d.ts.map +1 -0
  53. package/dist/validate-raw-ports.test.js +157 -0
  54. package/dist/validate-raw-ports.test.js.map +1 -0
  55. package/dist/validate-raw.d.ts.map +1 -1
  56. package/dist/validate-raw.js +141 -0
  57. package/dist/validate-raw.js.map +1 -1
  58. package/package.json +2 -7
  59. package/src/dag.test.ts +56 -0
  60. package/src/engine-ports.test.ts +404 -0
  61. package/src/engine.ts +231 -24
  62. package/src/pipeline-runner.ts +3 -0
  63. package/src/ports.test.ts +301 -0
  64. package/src/ports.ts +442 -0
  65. package/src/prompt-doc.test.ts +174 -0
  66. package/src/prompt-doc.ts +121 -1
  67. package/src/runner.test.ts +142 -0
  68. package/src/runner.ts +198 -8
  69. package/src/schema-ports.test.ts +236 -0
  70. package/src/schema.ts +8 -0
  71. package/src/sdk.ts +14 -0
  72. package/src/validate-raw-ports.test.ts +198 -0
  73. package/src/validate-raw.ts +155 -1
@@ -0,0 +1,236 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import yaml from 'js-yaml';
3
+ import type { PipelineConfig, RawPipelineConfig } from './types';
4
+ import {
5
+ deresolvePipeline,
6
+ parseYaml,
7
+ resolveConfig,
8
+ serializePipeline,
9
+ } from './schema';
10
+
11
+ const WORK_DIR = process.platform === 'win32' ? 'D:\\fake-work' : '/fake-work';
12
+
13
+ // ─── resolveConfig preserves ports ───────────────────────────────────
14
+
15
+ describe('resolveConfig — ports passthrough', () => {
16
+ test('raw ports survive onto the resolved task', () => {
17
+ const raw: RawPipelineConfig = {
18
+ name: 'p',
19
+ tracks: [
20
+ {
21
+ id: 't',
22
+ name: 'T',
23
+ tasks: [
24
+ {
25
+ id: 'a',
26
+ prompt: 'do it',
27
+ ports: {
28
+ inputs: [{ name: 'city', type: 'string', required: true }],
29
+ outputs: [{ name: 'temp', type: 'number', description: 'Celsius' }],
30
+ },
31
+ },
32
+ ],
33
+ },
34
+ ],
35
+ };
36
+ const resolved = resolveConfig(raw, WORK_DIR);
37
+ const task = resolved.tracks[0]!.tasks[0]!;
38
+ expect(task.ports).toBeDefined();
39
+ expect(task.ports!.inputs).toEqual([
40
+ { name: 'city', type: 'string', required: true },
41
+ ]);
42
+ expect(task.ports!.outputs).toEqual([
43
+ { name: 'temp', type: 'number', description: 'Celsius' },
44
+ ]);
45
+ });
46
+
47
+ test('tasks without ports still resolve with ports === undefined', () => {
48
+ const raw: RawPipelineConfig = {
49
+ name: 'p',
50
+ tracks: [
51
+ { id: 't', name: 'T', tasks: [{ id: 'a', prompt: 'do it' }] },
52
+ ],
53
+ };
54
+ const resolved = resolveConfig(raw, WORK_DIR);
55
+ expect(resolved.tracks[0]!.tasks[0]!.ports).toBeUndefined();
56
+ });
57
+
58
+ test('ports is not inherited from track or pipeline', () => {
59
+ // Ports describe a per-task I/O contract. If we accidentally pulled
60
+ // them from track defaults, two tasks in the same track would share
61
+ // input ports and downstream data-flow would be ambiguous. Test that
62
+ // a track with an unrelated `middlewares` default doesn't spread
63
+ // anywhere unexpected — purely a regression guard for the no-inherit
64
+ // invariant.
65
+ const raw: RawPipelineConfig = {
66
+ name: 'p',
67
+ tracks: [
68
+ {
69
+ id: 't',
70
+ name: 'T',
71
+ middlewares: [{ type: 'static_context', file: './x' }],
72
+ tasks: [{ id: 'a', prompt: 'x' }, { id: 'b', prompt: 'y' }],
73
+ },
74
+ ],
75
+ };
76
+ const resolved = resolveConfig(raw, WORK_DIR);
77
+ for (const task of resolved.tracks[0]!.tasks) {
78
+ expect(task.ports).toBeUndefined();
79
+ }
80
+ });
81
+ });
82
+
83
+ // ─── deresolvePipeline preserves ports ───────────────────────────────
84
+
85
+ describe('deresolvePipeline — ports round-trip', () => {
86
+ test('ports with both inputs and outputs round-trip', () => {
87
+ const raw: RawPipelineConfig = {
88
+ name: 'p',
89
+ tracks: [
90
+ {
91
+ id: 't',
92
+ name: 'T',
93
+ tasks: [
94
+ {
95
+ id: 'a',
96
+ prompt: 'hi',
97
+ ports: {
98
+ inputs: [{ name: 'city', type: 'string', required: true }],
99
+ outputs: [{ name: 'temp', type: 'number' }],
100
+ },
101
+ },
102
+ ],
103
+ },
104
+ ],
105
+ };
106
+ const resolved = resolveConfig(raw, WORK_DIR);
107
+ const back = deresolvePipeline(resolved, WORK_DIR);
108
+ expect(back.tracks[0]!.tasks[0]!.ports).toEqual(raw.tracks[0]!.tasks[0]!.ports!);
109
+ });
110
+
111
+ test('ports with only outputs round-trip', () => {
112
+ const raw: RawPipelineConfig = {
113
+ name: 'p',
114
+ tracks: [
115
+ {
116
+ id: 't',
117
+ name: 'T',
118
+ tasks: [
119
+ {
120
+ id: 'a',
121
+ command: 'echo hi',
122
+ ports: { outputs: [{ name: 'x', type: 'string' }] },
123
+ },
124
+ ],
125
+ },
126
+ ],
127
+ };
128
+ const resolved = resolveConfig(raw, WORK_DIR);
129
+ const back = deresolvePipeline(resolved, WORK_DIR);
130
+ expect(back.tracks[0]!.tasks[0]!.ports).toEqual({
131
+ outputs: [{ name: 'x', type: 'string' }],
132
+ });
133
+ });
134
+
135
+ test('empty ports ({}) is dropped on deresolve', () => {
136
+ // YAML round-trip prefers field absence over `ports: {}` so a task
137
+ // that once declared a port but had it cleared in the editor
138
+ // doesn't persist a useless empty object in the file.
139
+ const resolved: PipelineConfig = {
140
+ name: 'p',
141
+ tracks: [
142
+ {
143
+ id: 't',
144
+ name: 'T',
145
+ driver: 'opencode',
146
+ permissions: { read: true, write: false, execute: false },
147
+ on_failure: 'skip_downstream',
148
+ tasks: [
149
+ {
150
+ id: 'a',
151
+ name: 'a',
152
+ prompt: 'hi',
153
+ permissions: { read: true, write: false, execute: false },
154
+ driver: 'opencode',
155
+ ports: {},
156
+ },
157
+ ],
158
+ },
159
+ ],
160
+ };
161
+ const back = deresolvePipeline(resolved, WORK_DIR);
162
+ expect(back.tracks[0]!.tasks[0]!.ports).toBeUndefined();
163
+ });
164
+
165
+ test('YAML round-trip via serializePipeline preserves the full ports shape', () => {
166
+ const raw: RawPipelineConfig = {
167
+ name: 'p',
168
+ tracks: [
169
+ {
170
+ id: 't',
171
+ name: 'T',
172
+ tasks: [
173
+ {
174
+ id: 'classify',
175
+ prompt: 'pick a bucket',
176
+ ports: {
177
+ inputs: [
178
+ { name: 'doc', type: 'string', required: true, description: 'Full text' },
179
+ ],
180
+ outputs: [
181
+ {
182
+ name: 'bucket',
183
+ type: 'enum',
184
+ enum: ['spam', 'ham'],
185
+ description: 'Classification',
186
+ },
187
+ ],
188
+ },
189
+ },
190
+ ],
191
+ },
192
+ ],
193
+ };
194
+ const yamlText = serializePipeline(raw);
195
+ const parsed = (yaml.load(yamlText) as { pipeline: RawPipelineConfig }).pipeline;
196
+ expect(parsed.tracks[0]!.tasks[0]!.ports).toEqual(raw.tracks[0]!.tasks[0]!.ports!);
197
+ });
198
+ });
199
+
200
+ // ─── parseYaml accepts ports ─────────────────────────────────────────
201
+
202
+ describe('parseYaml — accepts ports declarations', () => {
203
+ test('real-world YAML with ports parses cleanly', () => {
204
+ const text = `pipeline:
205
+ name: demo
206
+ tracks:
207
+ - id: t
208
+ name: Main
209
+ tasks:
210
+ - id: plan
211
+ prompt: Pick a city and id
212
+ ports:
213
+ outputs:
214
+ - name: city
215
+ type: string
216
+ description: Target city
217
+ - name: id
218
+ type: number
219
+ - id: fetch
220
+ depends_on: [plan]
221
+ command: 'weather.sh --city "{{inputs.city}}" --id {{inputs.id}}'
222
+ ports:
223
+ inputs:
224
+ - { name: city, type: string, required: true }
225
+ - { name: id, type: number, required: true }
226
+ outputs:
227
+ - { name: temp, type: number }
228
+ `;
229
+ const config = parseYaml(text);
230
+ const plan = config.tracks[0]!.tasks[0]!;
231
+ const fetch = config.tracks[0]!.tasks[1]!;
232
+ expect(plan.ports!.outputs!.map((p) => p.name)).toEqual(['city', 'id']);
233
+ expect(fetch.ports!.inputs!.map((p) => p.name)).toEqual(['city', 'id']);
234
+ expect(fetch.ports!.outputs!.map((p) => p.name)).toEqual(['temp']);
235
+ });
236
+ });
package/src/schema.ts CHANGED
@@ -148,6 +148,9 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
148
148
  completion: rawTask.completion,
149
149
  agent_profile: rawTask.agent_profile ?? rawTrack.agent_profile,
150
150
  cwd: rawTask.cwd ? validatePath(rawTask.cwd, workDir) : trackCwd,
151
+ // Ports: no inheritance — they describe per-task I/O contract, not
152
+ // cross-task defaults. Passed through as-is (including `undefined`).
153
+ ports: rawTask.ports,
151
154
  };
152
155
  });
153
156
 
@@ -274,6 +277,11 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
274
277
  ...(task.permissions && !permissionsEqual(task.permissions, track.permissions)
275
278
  ? { permissions: task.permissions }
276
279
  : {}),
280
+ ...(task.ports &&
281
+ ((task.ports.inputs && task.ports.inputs.length > 0) ||
282
+ (task.ports.outputs && task.ports.outputs.length > 0))
283
+ ? { ports: task.ports }
284
+ : {}),
277
285
  };
278
286
  });
279
287
 
package/src/sdk.ts CHANGED
@@ -114,7 +114,21 @@ export {
114
114
  promptDocumentFromString,
115
115
  serializePromptDocument,
116
116
  appendContext,
117
+ prependContext,
118
+ renderInputsBlock,
119
+ renderOutputSchemaBlock,
117
120
  } from './prompt-doc';
118
121
 
122
+ // ── Task ports (editor: substitute placeholders, resolve upstream
123
+ // values, extract downstream outputs; drivers that wrap the prompt
124
+ // may want substituteInputs on their own envelope) ──
125
+ export {
126
+ substituteInputs,
127
+ extractInputReferences,
128
+ resolveTaskInputs,
129
+ extractTaskOutputs,
130
+ } from './ports';
131
+ export type { SubstituteResult, InputResolution, ExtractResult } from './ports';
132
+
119
133
  // ── All types from @tagma/types + runtime constants ──
120
134
  export * from './types';
@@ -0,0 +1,198 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { validateRaw } from './validate-raw';
3
+ import type { RawPipelineConfig, RawTaskConfig, RawTrackConfig, TaskPorts } from './types';
4
+
5
+ function task(overrides: Partial<RawTaskConfig> & { id: string }): RawTaskConfig {
6
+ return { prompt: 'do a thing', ...overrides };
7
+ }
8
+
9
+ function pipeline(tasks: RawTaskConfig[]): RawPipelineConfig {
10
+ const track: RawTrackConfig = { id: 't', name: 't', tasks };
11
+ return { name: 'test', tracks: [track] };
12
+ }
13
+
14
+ function errorsFor(taskConfig: RawTaskConfig): ReturnType<typeof validateRaw> {
15
+ return validateRaw(pipeline([taskConfig]));
16
+ }
17
+
18
+ /**
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.
22
+ */
23
+ function portsErrors(errors: ReturnType<typeof validateRaw>): typeof errors {
24
+ return errors.filter(
25
+ (e) => e.path.includes('.ports.') || e.path.includes('.ports[') || /\.ports$/.test(e.path),
26
+ );
27
+ }
28
+
29
+ // ─── Structural validation ───────────────────────────────────────────
30
+
31
+ describe('validateRaw — port structure', () => {
32
+ test('empty ports object is accepted (no-op)', () => {
33
+ const errors = errorsFor(task({ id: 'a', ports: {} }));
34
+ expect(portsErrors(errors)).toEqual([]);
35
+ });
36
+
37
+ test('rejects non-array ports.inputs', () => {
38
+ const ports = { inputs: 'not-an-array' as unknown as [] } as TaskPorts;
39
+ const errors = errorsFor(task({ id: 'a', ports }));
40
+ const e = portsErrors(errors);
41
+ expect(e.length).toBeGreaterThan(0);
42
+ expect(e[0]!.message).toMatch(/must be an array/);
43
+ });
44
+
45
+ test('rejects non-object port entry', () => {
46
+ const ports = { inputs: ['not-an-object' as unknown as never] } as TaskPorts;
47
+ const errors = errorsFor(task({ id: 'a', ports }));
48
+ expect(portsErrors(errors).some((e) => /must be an object/.test(e.message))).toBe(true);
49
+ });
50
+
51
+ test('requires port.name to be a non-empty string', () => {
52
+ const ports: TaskPorts = { inputs: [{ name: '', type: 'string' }] };
53
+ const errors = errorsFor(task({ id: 'a', ports }));
54
+ expect(portsErrors(errors).some((e) => /port\.name is required/.test(e.message))).toBe(true);
55
+ });
56
+
57
+ test('rejects invalid port name characters', () => {
58
+ const ports: TaskPorts = {
59
+ inputs: [
60
+ { name: 'has-hyphen', type: 'string' },
61
+ { name: '1starts-with-digit', type: 'string' },
62
+ { name: 'has.dot', type: 'string' },
63
+ ],
64
+ };
65
+ const errors = errorsFor(task({ id: 'a', ports }));
66
+ const msgs = portsErrors(errors).map((e) => e.message);
67
+ expect(msgs.filter((m) => /port name .* is invalid/.test(m)).length).toBe(3);
68
+ });
69
+
70
+ test('flags duplicate port names within the same list', () => {
71
+ const ports: TaskPorts = {
72
+ inputs: [
73
+ { name: 'x', type: 'string' },
74
+ { name: 'x', type: 'number' },
75
+ ],
76
+ };
77
+ const errors = errorsFor(task({ id: 'a', ports }));
78
+ expect(portsErrors(errors).some((e) => /Duplicate ports\.inputs name/.test(e.message))).toBe(
79
+ true,
80
+ );
81
+ });
82
+
83
+ test('rejects unknown port type', () => {
84
+ const ports = { inputs: [{ name: 'x', type: 'made-up' as never }] } as TaskPorts;
85
+ const errors = errorsFor(task({ id: 'a', ports }));
86
+ expect(portsErrors(errors).some((e) => /type must be one of/.test(e.message))).toBe(true);
87
+ });
88
+
89
+ test('enum port requires a non-empty enum array', () => {
90
+ const ports: TaskPorts = { inputs: [{ name: 'x', type: 'enum' }] };
91
+ const errors = errorsFor(task({ id: 'a', ports }));
92
+ expect(portsErrors(errors).some((e) => /non-empty "enum"/.test(e.message))).toBe(true);
93
+ });
94
+
95
+ test('enum values must all be strings', () => {
96
+ const ports = {
97
+ inputs: [{ name: 'x', type: 'enum' as const, enum: ['a', 1 as unknown as string] }],
98
+ } as TaskPorts;
99
+ const errors = errorsFor(task({ id: 'a', ports }));
100
+ expect(portsErrors(errors).some((e) => /enum values must all be strings/.test(e.message))).toBe(
101
+ true,
102
+ );
103
+ });
104
+
105
+ test('`from` must be a string', () => {
106
+ const ports = {
107
+ inputs: [
108
+ { name: 'x', type: 'string' as const, from: 42 as unknown as string },
109
+ ],
110
+ } as TaskPorts;
111
+ const errors = errorsFor(task({ id: 'a', ports }));
112
+ expect(portsErrors(errors).some((e) => /"from" must be a string/.test(e.message))).toBe(true);
113
+ });
114
+ });
115
+
116
+ // ─── Input/output separation ─────────────────────────────────────────
117
+
118
+ describe('validateRaw — input vs output constraints', () => {
119
+ test('`required` on an output emits a warning (not an error)', () => {
120
+ const ports: TaskPorts = {
121
+ outputs: [{ name: 'x', type: 'string', required: true }],
122
+ };
123
+ const errors = errorsFor(task({ id: 'a', ports, prompt: 'x' }));
124
+ const portErrs = portsErrors(errors);
125
+ expect(portErrs.length).toBeGreaterThan(0);
126
+ expect(portErrs[0]!.severity).toBe('warning');
127
+ expect(portErrs[0]!.message).toMatch(/input-only/);
128
+ });
129
+
130
+ test('`from` on an output also warns', () => {
131
+ const ports: TaskPorts = {
132
+ outputs: [{ name: 'x', type: 'string', from: 'whatever' }],
133
+ };
134
+ const errors = errorsFor(task({ id: 'a', ports }));
135
+ const portErrs = portsErrors(errors);
136
+ expect(portErrs[0]!.severity).toBe('warning');
137
+ });
138
+ });
139
+
140
+ // ─── {{inputs.X}} cross-check ────────────────────────────────────────
141
+
142
+ describe('validateRaw — placeholder cross-check', () => {
143
+ test('references to undeclared inputs in prompt are errors', () => {
144
+ const errors = errorsFor(
145
+ task({
146
+ id: 'a',
147
+ prompt: 'city={{inputs.city}} id={{inputs.id}}',
148
+ ports: { inputs: [{ name: 'city', type: 'string' }] },
149
+ }),
150
+ );
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);
154
+ });
155
+
156
+ test('references to undeclared inputs in command are errors', () => {
157
+ const errors = errorsFor(
158
+ task({
159
+ id: 'a',
160
+ prompt: undefined,
161
+ command: 'echo {{inputs.oops}}',
162
+ }),
163
+ );
164
+ expect(errors.some((e) => e.message.includes('references "{{inputs.oops}}"'))).toBe(true);
165
+ });
166
+
167
+ test('declared inputs with no references emit a warning for command tasks', () => {
168
+ const errors = errorsFor(
169
+ task({
170
+ id: 'a',
171
+ prompt: undefined,
172
+ command: 'echo hi',
173
+ ports: { inputs: [{ name: 'unused', type: 'string' }] },
174
+ }),
175
+ );
176
+ const warnings = errors.filter(
177
+ (e) => e.severity === 'warning' && /declared input is unused/.test(e.message),
178
+ );
179
+ expect(warnings.length).toBe(1);
180
+ });
181
+
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.
186
+ const errors = errorsFor(
187
+ task({
188
+ id: 'a',
189
+ prompt: 'do the thing',
190
+ ports: { inputs: [{ name: 'context', type: 'string' }] },
191
+ }),
192
+ );
193
+ const warnings = errors.filter(
194
+ (e) => e.severity === 'warning' && /declared input is unused/.test(e.message),
195
+ );
196
+ expect(warnings.length).toBe(0);
197
+ });
198
+ });
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // Returns a flat list of ValidationError objects. An empty array means valid.
8
8
 
9
- import type { RawPipelineConfig } from './types';
9
+ import type { PortDef, PortType, RawPipelineConfig, RawTaskConfig } from './types';
10
10
  import {
11
11
  isValidTaskId,
12
12
  qualifyTaskId,
@@ -14,6 +14,7 @@ import {
14
14
  resolveTaskRef,
15
15
  type TaskIndex,
16
16
  } from './task-ref';
17
+ import { extractInputReferences } from './ports';
17
18
 
18
19
  const DURATION_RE = /^(\d*\.?\d+)\s*(s|m|h|d)$/;
19
20
  function isValidDuration(input: string): boolean {
@@ -284,6 +285,9 @@ export function validateRaw(
284
285
  }
285
286
  }
286
287
 
288
+ // ── Port declaration checks ──
289
+ validateTaskPorts(task, taskPath, errors);
290
+
287
291
  // ── depends_on reference checks ──
288
292
  if (task.depends_on && task.depends_on.length > 0) {
289
293
  for (const dep of task.depends_on) {
@@ -343,6 +347,156 @@ export function validateRaw(
343
347
  return errors;
344
348
  }
345
349
 
350
+ const VALID_PORT_TYPES: ReadonlySet<PortType> = new Set([
351
+ 'string',
352
+ 'number',
353
+ 'boolean',
354
+ 'enum',
355
+ 'json',
356
+ ]);
357
+
358
+ // Identifier pattern for port names. Deliberately narrower than task IDs —
359
+ // port names appear in `{{inputs.<name>}}` templates where hyphens would
360
+ // be parsed as subtraction, so we also forbid them here to keep the
361
+ // template grammar unambiguous.
362
+ const PORT_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
363
+
364
+ function validatePortList(
365
+ list: readonly PortDef[] | undefined,
366
+ basePath: string,
367
+ kind: 'inputs' | 'outputs',
368
+ errors: ValidationError[],
369
+ ): void {
370
+ if (!list) return;
371
+ if (!Array.isArray(list)) {
372
+ errors.push({
373
+ path: basePath,
374
+ message: `ports.${kind} must be an array`,
375
+ });
376
+ return;
377
+ }
378
+ const seen = new Set<string>();
379
+ for (let i = 0; i < list.length; i++) {
380
+ const port = list[i];
381
+ const path = `${basePath}[${i}]`;
382
+ if (!port || typeof port !== 'object') {
383
+ errors.push({ path, message: `ports.${kind}[${i}] must be an object` });
384
+ continue;
385
+ }
386
+ if (typeof port.name !== 'string' || !port.name.trim()) {
387
+ errors.push({ path: `${path}.name`, message: 'port.name is required' });
388
+ continue;
389
+ }
390
+ if (!PORT_NAME_RE.test(port.name)) {
391
+ errors.push({
392
+ path: `${path}.name`,
393
+ message: `port name "${port.name}" is invalid. Must match /^[A-Za-z_][A-Za-z0-9_]*$/ (letters, digits, underscores; starts with letter/underscore).`,
394
+ });
395
+ }
396
+ if (seen.has(port.name)) {
397
+ errors.push({
398
+ path,
399
+ message: `Duplicate ports.${kind} name "${port.name}"`,
400
+ });
401
+ }
402
+ seen.add(port.name);
403
+ if (!VALID_PORT_TYPES.has(port.type)) {
404
+ errors.push({
405
+ path: `${path}.type`,
406
+ message: `port "${port.name}": type must be one of ${[...VALID_PORT_TYPES].join(', ')} (got ${JSON.stringify(port.type)})`,
407
+ });
408
+ }
409
+ if (port.type === 'enum') {
410
+ if (!Array.isArray(port.enum) || port.enum.length === 0) {
411
+ errors.push({
412
+ path: `${path}.enum`,
413
+ message: `port "${port.name}": enum type requires a non-empty "enum" array`,
414
+ });
415
+ } else if (port.enum.some((v: unknown) => typeof v !== 'string')) {
416
+ errors.push({
417
+ path: `${path}.enum`,
418
+ message: `port "${port.name}": enum values must all be strings`,
419
+ });
420
+ }
421
+ }
422
+ if (kind === 'outputs' && (port.required === true || port.from !== undefined)) {
423
+ // `required` / `from` are input-only concepts — outputs are
424
+ // always "produced when the task succeeds". Warn softly so the
425
+ // YAML doesn't silently accept meaningless fields.
426
+ errors.push({
427
+ path,
428
+ severity: 'warning',
429
+ message: `port "${port.name}": "required" and "from" are input-only; ignored on outputs`,
430
+ });
431
+ }
432
+ if (port.from !== undefined && typeof port.from !== 'string') {
433
+ errors.push({
434
+ path: `${path}.from`,
435
+ message: `port "${port.name}": "from" must be a string (got ${typeof port.from})`,
436
+ });
437
+ }
438
+ }
439
+ }
440
+
441
+ function validateTaskPorts(
442
+ task: RawTaskConfig,
443
+ taskPath: string,
444
+ errors: ValidationError[],
445
+ ): void {
446
+ const ports = task.ports;
447
+ // Placeholder cross-checks are independent of ports being declared —
448
+ // a user can type `{{inputs.X}}` without declaring any ports yet, and
449
+ // that's always an error (the engine has no `X` to substitute, and
450
+ // `validate-raw` is the one place that surfaces this before a run).
451
+ // Running the check unconditionally catches the typo on its own.
452
+ const declaredInputs = new Set<string>(
453
+ ports && Array.isArray(ports.inputs)
454
+ ? ports.inputs.filter((p): p is PortDef => !!p && typeof p === 'object').map((p) => p.name)
455
+ : [],
456
+ );
457
+ const referenced = new Set<string>();
458
+ if (typeof task.prompt === 'string') {
459
+ for (const n of extractInputReferences(task.prompt)) referenced.add(n);
460
+ }
461
+ if (typeof task.command === 'string') {
462
+ for (const n of extractInputReferences(task.command)) referenced.add(n);
463
+ }
464
+ for (const name of referenced) {
465
+ if (!declaredInputs.has(name)) {
466
+ errors.push({
467
+ path: taskPath,
468
+ message: `Task "${task.id}": references "{{inputs.${name}}}" but no such input port is declared`,
469
+ });
470
+ }
471
+ }
472
+
473
+ if (!ports) return;
474
+
475
+ // Per-port structural validation runs only after we've established
476
+ // that `ports.inputs` / `ports.outputs` are arrays — validatePortList
477
+ // also re-checks Array.isArray internally, which keeps it callable
478
+ // from contexts that hand it stray values.
479
+ validatePortList(ports.inputs, `${taskPath}.ports.inputs`, 'inputs', errors);
480
+ validatePortList(ports.outputs, `${taskPath}.ports.outputs`, 'outputs', errors);
481
+
482
+ // Warn on declared-but-unused inputs. Not fatal — a user may want to
483
+ // surface an input as a data-flow hint for the editor even when the
484
+ // prompt/command doesn't template it explicitly (e.g. AI tasks that
485
+ // consume inputs through the `[Inputs]` context block).
486
+ if (typeof task.command === 'string' && Array.isArray(ports.inputs)) {
487
+ for (const port of ports.inputs) {
488
+ if (!port || typeof port !== 'object') continue;
489
+ if (!referenced.has(port.name)) {
490
+ errors.push({
491
+ path: `${taskPath}.ports.inputs`,
492
+ severity: 'warning',
493
+ message: `Task "${task.id}": command does not reference {{inputs.${port.name}}} — declared input is unused`,
494
+ });
495
+ }
496
+ }
497
+ }
498
+ }
499
+
346
500
  function detectCycles(config: RawPipelineConfig, index: TaskIndex): ValidationError[] {
347
501
  // Build adjacency: qualifiedId → [resolved dep qualifiedIds]
348
502
  const adj = new Map<string, string[]>();