@tagma/sdk 0.6.10 → 0.6.12
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/README.md +93 -13
- package/dist/config-ops.d.ts +4 -2
- package/dist/config-ops.d.ts.map +1 -1
- package/dist/config-ops.js +30 -2
- package/dist/config-ops.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +75 -27
- package/dist/engine.js.map +1 -1
- package/dist/pipeline-runner.d.ts +1 -1
- package/dist/pipeline-runner.d.ts.map +1 -1
- package/dist/pipeline-runner.js +20 -0
- package/dist/pipeline-runner.js.map +1 -1
- package/dist/ports.d.ts +23 -1
- package/dist/ports.d.ts.map +1 -1
- package/dist/ports.js +160 -0
- package/dist/ports.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +47 -11
- package/dist/schema.js.map +1 -1
- package/dist/sdk.d.ts +2 -2
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +1 -1
- package/dist/sdk.js.map +1 -1
- package/dist/task-ref.d.ts.map +1 -1
- package/dist/task-ref.js +2 -0
- package/dist/task-ref.js.map +1 -1
- package/dist/utils.js +3 -3
- package/dist/utils.js.map +1 -1
- package/dist/validate-raw.d.ts.map +1 -1
- package/dist/validate-raw.js +167 -3
- package/dist/validate-raw.js.map +1 -1
- package/dist/yaml-compiler.d.ts.map +1 -1
- package/dist/yaml-compiler.js +23 -5
- package/dist/yaml-compiler.js.map +1 -1
- package/package.json +2 -2
- package/src/completions/output-check.test.ts +50 -0
- package/src/config-ops.test.ts +70 -0
- package/src/config-ops.ts +23 -2
- package/src/engine-ports.test.ts +66 -0
- package/src/engine-task-type.test.ts +56 -0
- package/src/engine.ts +100 -26
- package/src/pipeline-runner.test.ts +95 -0
- package/src/pipeline-runner.ts +18 -1
- package/src/ports.test.ts +127 -0
- package/src/ports.ts +224 -1
- package/src/schema-ports.test.ts +86 -0
- package/src/schema.test.ts +113 -1
- package/src/schema.ts +52 -13
- package/src/sdk.ts +4 -0
- package/src/task-ref.ts +1 -0
- package/src/utils.test.ts +28 -0
- package/src/utils.ts +3 -3
- package/src/validate-raw-ports.test.ts +66 -0
- package/src/validate-raw.ts +189 -4
- package/src/yaml-compiler.test.ts +108 -0
- package/src/yaml-compiler.ts +32 -5
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { validatePath } from './utils';
|
|
6
|
+
|
|
7
|
+
describe('validatePath', () => {
|
|
8
|
+
test('rejects real parent traversal outside the project root', () => {
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), 'tagma-validate-path-'));
|
|
10
|
+
try {
|
|
11
|
+
expect(() => validatePath('../outside', root)).toThrow(/escapes project root/);
|
|
12
|
+
} finally {
|
|
13
|
+
rmSync(root, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('allows project-local names that merely start with two dots', () => {
|
|
18
|
+
const root = mkdtempSync(join(tmpdir(), 'tagma-validate-path-'));
|
|
19
|
+
try {
|
|
20
|
+
const inside = join(root, '..inside');
|
|
21
|
+
mkdirSync(inside);
|
|
22
|
+
|
|
23
|
+
expect(validatePath('..inside', root)).toBe(inside);
|
|
24
|
+
} finally {
|
|
25
|
+
rmSync(root, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
});
|
package/src/utils.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve, relative, parse as parsePath } from 'path';
|
|
1
|
+
import { isAbsolute, resolve, relative, parse as parsePath, sep } from 'path';
|
|
2
2
|
import { realpathSync, lstatSync, existsSync } from 'fs';
|
|
3
3
|
import { randomBytes } from 'crypto';
|
|
4
4
|
|
|
@@ -38,7 +38,7 @@ export function validatePath(filePath: string, projectRoot: string): string {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
const rel = relative(projectRoot, resolved);
|
|
41
|
-
if (rel
|
|
41
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
42
42
|
throw new Error(
|
|
43
43
|
`Security: path "${filePath}" escapes project root. ` +
|
|
44
44
|
`All file references must be within "${projectRoot}".`,
|
|
@@ -83,7 +83,7 @@ export function validatePath(filePath: string, projectRoot: string): string {
|
|
|
83
83
|
);
|
|
84
84
|
}
|
|
85
85
|
const realRel = relative(realRoot, real);
|
|
86
|
-
if (realRel
|
|
86
|
+
if (realRel === '..' || realRel.startsWith(`..${sep}`) || isAbsolute(realRel)) {
|
|
87
87
|
throw new Error(
|
|
88
88
|
`Security: path "${filePath}" resolves via symlink to "${real}" which escapes project root "${realRoot}".`,
|
|
89
89
|
);
|
|
@@ -38,6 +38,12 @@ function portsErrors(errors: ReturnType<typeof validateRaw>): typeof errors {
|
|
|
38
38
|
);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function bindingErrors(errors: ReturnType<typeof validateRaw>): typeof errors {
|
|
42
|
+
return errors.filter(
|
|
43
|
+
(e) => e.path.includes('.inputs') || e.path.includes('.outputs'),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
41
47
|
// ─── Structural validation (Command Tasks) ───────────────────────────
|
|
42
48
|
|
|
43
49
|
describe('validateRaw — port structure (command tasks)', () => {
|
|
@@ -123,6 +129,66 @@ describe('validateRaw — port structure (command tasks)', () => {
|
|
|
123
129
|
});
|
|
124
130
|
});
|
|
125
131
|
|
|
132
|
+
// ─── Lightweight binding validation ──────────────────────────────────
|
|
133
|
+
|
|
134
|
+
describe('validateRaw — lightweight task bindings', () => {
|
|
135
|
+
test('accepts top-level inputs for command placeholder references', () => {
|
|
136
|
+
const errors = errorsFor(
|
|
137
|
+
commandTask({
|
|
138
|
+
id: 'a',
|
|
139
|
+
command: 'echo {{inputs.city}}',
|
|
140
|
+
inputs: { city: { value: 'Shanghai' } },
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
expect(errors.some((e) => e.message.includes('references "{{inputs.city}}"'))).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('rejects non-object binding maps and entries', () => {
|
|
147
|
+
const errors = errorsFor(
|
|
148
|
+
commandTask({
|
|
149
|
+
id: 'a',
|
|
150
|
+
inputs: 'bad' as unknown as never,
|
|
151
|
+
outputs: { ok: 'bad' as unknown as never },
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
const msgs = bindingErrors(errors).map((e) => e.message);
|
|
155
|
+
expect(msgs.some((m) => /task\.inputs must be an object/.test(m))).toBe(true);
|
|
156
|
+
expect(msgs.some((m) => /task\.outputs\.ok must be an object/.test(m))).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('rejects invalid binding names and duplicate loose/strict names', () => {
|
|
160
|
+
const errors = errorsFor(
|
|
161
|
+
commandTask({
|
|
162
|
+
id: 'a',
|
|
163
|
+
inputs: { 'bad-name': { value: 'x' }, city: { value: 'Shanghai' } },
|
|
164
|
+
outputs: { report: { from: 'stdout' } },
|
|
165
|
+
ports: {
|
|
166
|
+
inputs: [{ name: 'city', type: 'string' }],
|
|
167
|
+
outputs: [{ name: 'report', type: 'string' }],
|
|
168
|
+
},
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
const msgs = errors.map((e) => e.message);
|
|
172
|
+
expect(msgs.some((m) => /binding name "bad-name" is invalid/.test(m))).toBe(true);
|
|
173
|
+
expect(msgs.some((m) => /duplicates strict ports\.inputs/.test(m))).toBe(true);
|
|
174
|
+
expect(msgs.some((m) => /duplicates strict ports\.outputs/.test(m))).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('fully-qualified binding sources must reference direct dependencies', () => {
|
|
178
|
+
const errors = validateRaw(
|
|
179
|
+
pipeline([
|
|
180
|
+
commandTask({ id: 'up', outputs: { city: {} } }),
|
|
181
|
+
commandTask({
|
|
182
|
+
id: 'down',
|
|
183
|
+
depends_on: [],
|
|
184
|
+
inputs: { city: { from: 't.up.outputs.city', required: true } },
|
|
185
|
+
}),
|
|
186
|
+
]),
|
|
187
|
+
);
|
|
188
|
+
expect(errors.some((e) => /not a direct dependency/.test(e.message))).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
126
192
|
// ─── Input/output separation (Command Tasks) ─────────────────────────
|
|
127
193
|
|
|
128
194
|
describe('validateRaw — input vs output constraints (command tasks)', () => {
|
package/src/validate-raw.ts
CHANGED
|
@@ -32,6 +32,7 @@ function buildQidIndex(config: RawPipelineConfig): Map<string, QidEntry> {
|
|
|
32
32
|
const idx = new Map<string, QidEntry>();
|
|
33
33
|
for (const track of config.tracks ?? []) {
|
|
34
34
|
if (!track.id) continue;
|
|
35
|
+
if (!Array.isArray(track.tasks)) continue;
|
|
35
36
|
for (const task of track.tasks ?? []) {
|
|
36
37
|
if (!task.id) continue;
|
|
37
38
|
idx.set(qualifyTaskId(track.id, task.id), { track, task });
|
|
@@ -55,6 +56,7 @@ const isValidId = isValidTaskId;
|
|
|
55
56
|
|
|
56
57
|
const VALID_ON_FAILURE = new Set(['skip_downstream', 'stop_all', 'ignore']);
|
|
57
58
|
const VALID_REASONING_EFFORT = new Set(['low', 'medium', 'high']);
|
|
59
|
+
const PERMISSION_FIELDS = ['read', 'write', 'execute'] as const;
|
|
58
60
|
|
|
59
61
|
// Built-in plugin types always known to the SDK core, regardless of which
|
|
60
62
|
// external plugin packages are installed. These MUST stay in sync with the
|
|
@@ -150,8 +152,13 @@ export function validateRaw(
|
|
|
150
152
|
severity: 'warning',
|
|
151
153
|
});
|
|
152
154
|
}
|
|
155
|
+
validatePermissions(config.permissions, 'permissions', errors);
|
|
153
156
|
|
|
154
|
-
if (!
|
|
157
|
+
if (!Array.isArray(config.tracks)) {
|
|
158
|
+
errors.push({ path: 'tracks', message: 'pipeline.tracks must be an array' });
|
|
159
|
+
return errors;
|
|
160
|
+
}
|
|
161
|
+
if (config.tracks.length === 0) {
|
|
155
162
|
errors.push({ path: 'tracks', message: 'At least one track is required' });
|
|
156
163
|
return errors; // No point going further without tracks
|
|
157
164
|
}
|
|
@@ -168,8 +175,13 @@ export function validateRaw(
|
|
|
168
175
|
// ── Per-track validation ──
|
|
169
176
|
const seenTrackIds = new Set<string>();
|
|
170
177
|
for (let ti = 0; ti < config.tracks.length; ti++) {
|
|
171
|
-
const
|
|
178
|
+
const maybeTrack = config.tracks[ti] as unknown;
|
|
172
179
|
const trackPath = `tracks[${ti}]`;
|
|
180
|
+
if (!maybeTrack || typeof maybeTrack !== 'object' || Array.isArray(maybeTrack)) {
|
|
181
|
+
errors.push({ path: trackPath, message: `Track ${ti} must be an object` });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const track = maybeTrack as RawTrackConfig;
|
|
173
185
|
|
|
174
186
|
if (!track.id?.trim()) {
|
|
175
187
|
errors.push({ path: `${trackPath}.id`, message: 'Track id is required' });
|
|
@@ -205,6 +217,7 @@ export function validateRaw(
|
|
|
205
217
|
severity: 'warning',
|
|
206
218
|
});
|
|
207
219
|
}
|
|
220
|
+
validatePermissions(track.permissions, `${trackPath}.permissions`, errors);
|
|
208
221
|
|
|
209
222
|
// Track-level middlewares can reference a plugin that was uninstalled
|
|
210
223
|
// after the YAML was written — surface a warning so the user notices
|
|
@@ -222,7 +235,14 @@ export function validateRaw(
|
|
|
222
235
|
}
|
|
223
236
|
}
|
|
224
237
|
|
|
225
|
-
if (!
|
|
238
|
+
if (!Array.isArray(track.tasks)) {
|
|
239
|
+
errors.push({
|
|
240
|
+
path: `${trackPath}.tasks`,
|
|
241
|
+
message: `Track "${track.id || ti}": tasks must be an array`,
|
|
242
|
+
});
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (track.tasks.length === 0) {
|
|
226
246
|
errors.push({
|
|
227
247
|
path: `${trackPath}.tasks`,
|
|
228
248
|
message: `Track "${track.id || ti}": must have at least one task`,
|
|
@@ -302,6 +322,7 @@ export function validateRaw(
|
|
|
302
322
|
severity: 'warning',
|
|
303
323
|
});
|
|
304
324
|
}
|
|
325
|
+
validatePermissions(task.permissions, `${taskPath}.permissions`, errors);
|
|
305
326
|
|
|
306
327
|
// ── Plugin type warnings (trigger / completion / middlewares) ──
|
|
307
328
|
// Only fire when the host supplied a `knownTypes` snapshot, so offline
|
|
@@ -374,7 +395,7 @@ export function validateRaw(
|
|
|
374
395
|
});
|
|
375
396
|
} else if (
|
|
376
397
|
!task.depends_on ||
|
|
377
|
-
!task.depends_on.some((dep) => {
|
|
398
|
+
!task.depends_on.some((dep: string) => {
|
|
378
399
|
const depResolved = resolveTaskRef(dep, track.id, index);
|
|
379
400
|
return depResolved.kind === 'resolved' && depResolved.qid === resolved.qid;
|
|
380
401
|
})
|
|
@@ -400,6 +421,32 @@ export function validateRaw(
|
|
|
400
421
|
return errors;
|
|
401
422
|
}
|
|
402
423
|
|
|
424
|
+
function validatePermissions(
|
|
425
|
+
value: unknown,
|
|
426
|
+
basePath: string,
|
|
427
|
+
errors: ValidationError[],
|
|
428
|
+
): void {
|
|
429
|
+
if (value === undefined) return;
|
|
430
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
431
|
+
errors.push({
|
|
432
|
+
path: basePath,
|
|
433
|
+
message: 'permissions must be an object with read/write/execute booleans',
|
|
434
|
+
});
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const p = value as Record<string, unknown>;
|
|
438
|
+
for (const field of PERMISSION_FIELDS) {
|
|
439
|
+
const path = `${basePath}.${field}`;
|
|
440
|
+
if (!(field in p)) {
|
|
441
|
+
errors.push({ path, message: `permissions.${field} is required` });
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (typeof p[field] !== 'boolean') {
|
|
445
|
+
errors.push({ path, message: `permissions.${field} must be a boolean` });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
403
450
|
const VALID_PORT_TYPES: ReadonlySet<PortType> = new Set([
|
|
404
451
|
'string',
|
|
405
452
|
'number',
|
|
@@ -491,6 +538,136 @@ function validatePortList(
|
|
|
491
538
|
}
|
|
492
539
|
}
|
|
493
540
|
|
|
541
|
+
function validateBindingMap(
|
|
542
|
+
value: unknown,
|
|
543
|
+
basePath: string,
|
|
544
|
+
kind: 'inputs' | 'outputs',
|
|
545
|
+
errors: ValidationError[],
|
|
546
|
+
): void {
|
|
547
|
+
if (value === undefined) return;
|
|
548
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
549
|
+
errors.push({ path: basePath, message: `task.${kind} must be an object map` });
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const map = value as Record<string, unknown>;
|
|
554
|
+
for (const [name, rawBinding] of Object.entries(map)) {
|
|
555
|
+
const path = `${basePath}.${name}`;
|
|
556
|
+
if (!PORT_NAME_RE.test(name)) {
|
|
557
|
+
errors.push({
|
|
558
|
+
path,
|
|
559
|
+
message: `binding name "${name}" is invalid. Must match /^[A-Za-z_][A-Za-z0-9_]*$/.`,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
if (!rawBinding || typeof rawBinding !== 'object' || Array.isArray(rawBinding)) {
|
|
563
|
+
errors.push({ path, message: `task.${kind}.${name} must be an object` });
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
const binding = rawBinding as Record<string, unknown>;
|
|
567
|
+
if ('from' in binding && typeof binding.from !== 'string') {
|
|
568
|
+
errors.push({ path: `${path}.from`, message: `task.${kind}.${name}.from must be a string` });
|
|
569
|
+
}
|
|
570
|
+
if (kind === 'inputs' && 'required' in binding && typeof binding.required !== 'boolean') {
|
|
571
|
+
errors.push({
|
|
572
|
+
path: `${path}.required`,
|
|
573
|
+
message: `task.inputs.${name}.required must be a boolean`,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
if (kind === 'outputs' && typeof binding.from === 'string') {
|
|
577
|
+
const source = binding.from;
|
|
578
|
+
const ok =
|
|
579
|
+
source === 'stdout' ||
|
|
580
|
+
source === 'stderr' ||
|
|
581
|
+
source === 'normalizedOutput' ||
|
|
582
|
+
/^json\.[A-Za-z_][A-Za-z0-9_]*$/.test(source);
|
|
583
|
+
if (!ok) {
|
|
584
|
+
errors.push({
|
|
585
|
+
path: `${path}.from`,
|
|
586
|
+
message: `task.outputs.${name}.from must be stdout, stderr, normalizedOutput, or json.<key>`,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function validateBindingPortNameOverlap(
|
|
594
|
+
task: RawTaskConfig,
|
|
595
|
+
taskPath: string,
|
|
596
|
+
errors: ValidationError[],
|
|
597
|
+
): void {
|
|
598
|
+
const looseInputs = objectKeys(task.inputs);
|
|
599
|
+
const looseOutputs = objectKeys(task.outputs);
|
|
600
|
+
const strictInputs = new Set(
|
|
601
|
+
Array.isArray(task.ports?.inputs) ? task.ports.inputs.map((p) => p?.name) : [],
|
|
602
|
+
);
|
|
603
|
+
const strictOutputs = new Set(
|
|
604
|
+
Array.isArray(task.ports?.outputs) ? task.ports.outputs.map((p) => p?.name) : [],
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
for (const name of looseInputs) {
|
|
608
|
+
if (strictInputs.has(name)) {
|
|
609
|
+
errors.push({
|
|
610
|
+
path: `${taskPath}.inputs.${name}`,
|
|
611
|
+
message: `task input binding "${name}" duplicates strict ports.inputs; choose one layer for this name`,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
for (const name of looseOutputs) {
|
|
616
|
+
if (strictOutputs.has(name)) {
|
|
617
|
+
errors.push({
|
|
618
|
+
path: `${taskPath}.outputs.${name}`,
|
|
619
|
+
message: `task output binding "${name}" duplicates strict ports.outputs; choose one layer for this name`,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function objectKeys(value: unknown): string[] {
|
|
626
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
|
|
627
|
+
return Object.keys(value as Record<string, unknown>);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function validateInputBindingSources(
|
|
631
|
+
task: RawTaskConfig,
|
|
632
|
+
trackId: string,
|
|
633
|
+
taskPath: string,
|
|
634
|
+
index: TaskIndex,
|
|
635
|
+
errors: ValidationError[],
|
|
636
|
+
): void {
|
|
637
|
+
if (!task.inputs || typeof task.inputs !== 'object' || Array.isArray(task.inputs)) return;
|
|
638
|
+
for (const [name, rawBinding] of Object.entries(task.inputs)) {
|
|
639
|
+
if (!rawBinding || typeof rawBinding !== 'object' || Array.isArray(rawBinding)) continue;
|
|
640
|
+
const source = (rawBinding as Record<string, unknown>).from;
|
|
641
|
+
if (typeof source !== 'string') continue;
|
|
642
|
+
const upstreamId = bindingSourceTaskId(source);
|
|
643
|
+
if (!upstreamId) continue;
|
|
644
|
+
const deps = task.depends_on ?? [];
|
|
645
|
+
const isDirectDep = deps.some((dep) => {
|
|
646
|
+
const resolved = resolveTaskRef(dep, trackId, index);
|
|
647
|
+
return resolved.kind === 'resolved' && resolved.qid === upstreamId;
|
|
648
|
+
});
|
|
649
|
+
if (!isDirectDep) {
|
|
650
|
+
errors.push({
|
|
651
|
+
path: `${taskPath}.inputs.${name}.from`,
|
|
652
|
+
message: `Task "${task.id}": input binding "${name}" from "${source}" references task "${upstreamId}" which is not a direct dependency (must be listed in depends_on)`,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function bindingSourceTaskId(source: string): string | null {
|
|
659
|
+
const outputMarker = '.outputs.';
|
|
660
|
+
const outputIdx = source.lastIndexOf(outputMarker);
|
|
661
|
+
if (outputIdx > 0) return source.slice(0, outputIdx);
|
|
662
|
+
for (const field of ['stdout', 'stderr', 'normalizedOutput', 'exitCode']) {
|
|
663
|
+
const suffix = `.${field}`;
|
|
664
|
+
if (source.endsWith(suffix) && source.length > suffix.length) {
|
|
665
|
+
return source.slice(0, -suffix.length);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
|
|
494
671
|
function validateTaskPorts(
|
|
495
672
|
task: RawTaskConfig,
|
|
496
673
|
trackId: string,
|
|
@@ -503,6 +680,11 @@ function validateTaskPorts(
|
|
|
503
680
|
const isPromptTask = typeof task.prompt === 'string' && typeof task.command !== 'string';
|
|
504
681
|
const isCommandTask = typeof task.command === 'string' && typeof task.prompt !== 'string';
|
|
505
682
|
|
|
683
|
+
validateBindingMap(task.inputs, `${taskPath}.inputs`, 'inputs', errors);
|
|
684
|
+
validateBindingMap(task.outputs, `${taskPath}.outputs`, 'outputs', errors);
|
|
685
|
+
validateBindingPortNameOverlap(task, taskPath, errors);
|
|
686
|
+
validateInputBindingSources(task, trackId, taskPath, index, errors);
|
|
687
|
+
|
|
506
688
|
// ─── Prompt tasks do not declare ports ──
|
|
507
689
|
//
|
|
508
690
|
// A Prompt Task's I/O contract is inferred from direct-neighbor
|
|
@@ -538,6 +720,7 @@ function validateTaskPorts(
|
|
|
538
720
|
let availableInputs: Set<string>;
|
|
539
721
|
if (isPromptTask) {
|
|
540
722
|
availableInputs = collectUpstreamCommandOutputNames(task, trackId, qidIndex, index);
|
|
723
|
+
for (const name of objectKeys(task.inputs)) availableInputs.add(name);
|
|
541
724
|
} else {
|
|
542
725
|
// Command Task (or the pathological both-keys case, which is caught
|
|
543
726
|
// earlier as a separate error — tolerate it here).
|
|
@@ -546,6 +729,7 @@ function validateTaskPorts(
|
|
|
546
729
|
? ports.inputs.filter((p): p is PortDef => !!p && typeof p === 'object').map((p) => p.name)
|
|
547
730
|
: [],
|
|
548
731
|
);
|
|
732
|
+
for (const name of objectKeys(task.inputs)) availableInputs.add(name);
|
|
549
733
|
}
|
|
550
734
|
|
|
551
735
|
for (const name of referenced) {
|
|
@@ -764,6 +948,7 @@ function detectCycles(config: RawPipelineConfig, index: TaskIndex): ValidationEr
|
|
|
764
948
|
|
|
765
949
|
for (const track of config.tracks) {
|
|
766
950
|
if (!track.id) continue;
|
|
951
|
+
if (!Array.isArray(track.tasks)) continue;
|
|
767
952
|
for (const task of track.tasks ?? []) {
|
|
768
953
|
if (!task.id) continue;
|
|
769
954
|
const qid = qualifyTaskId(track.id, task.id);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { compileYamlContent } from './yaml-compiler';
|
|
3
|
+
|
|
4
|
+
describe('compileYamlContent', () => {
|
|
5
|
+
test('reports YAML syntax failures as parse errors', () => {
|
|
6
|
+
const result = compileYamlContent('pipeline:\n name: [');
|
|
7
|
+
|
|
8
|
+
expect(result.parseOk).toBe(false);
|
|
9
|
+
expect(result.success).toBe(false);
|
|
10
|
+
expect(result.validation.errors).toEqual([]);
|
|
11
|
+
expect(result.summary).toMatch(/^YAML parse error:/);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('reports missing top-level pipeline as a validation error', () => {
|
|
15
|
+
const result = compileYamlContent('name: Missing Pipeline\n');
|
|
16
|
+
|
|
17
|
+
expect(result.parseOk).toBe(true);
|
|
18
|
+
expect(result.success).toBe(false);
|
|
19
|
+
expect(result.validation.errors).toEqual([
|
|
20
|
+
{ path: 'pipeline', message: 'Top-level "pipeline" key is required' },
|
|
21
|
+
]);
|
|
22
|
+
expect(result.summary).toBe('Invalid: 1 error(s), 0 warning(s)');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('reports non-array tracks as a validation error, not a parse failure', () => {
|
|
26
|
+
const result = compileYamlContent(`
|
|
27
|
+
pipeline:
|
|
28
|
+
name: Bad
|
|
29
|
+
tracks:
|
|
30
|
+
id: not-an-array
|
|
31
|
+
`);
|
|
32
|
+
|
|
33
|
+
expect(result.parseOk).toBe(true);
|
|
34
|
+
expect(result.success).toBe(false);
|
|
35
|
+
expect(result.validation.errors).toEqual([
|
|
36
|
+
{ path: 'tracks', message: 'pipeline.tracks must be an array' },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('reports non-array task lists as validation errors, not validation crashes', () => {
|
|
41
|
+
const result = compileYamlContent(`
|
|
42
|
+
pipeline:
|
|
43
|
+
name: Bad Tasks
|
|
44
|
+
tracks:
|
|
45
|
+
- id: t
|
|
46
|
+
name: T
|
|
47
|
+
tasks:
|
|
48
|
+
id: not-an-array
|
|
49
|
+
`);
|
|
50
|
+
|
|
51
|
+
expect(result.parseOk).toBe(true);
|
|
52
|
+
expect(result.success).toBe(false);
|
|
53
|
+
expect(result.validation.errors).toEqual([
|
|
54
|
+
{ path: 'tracks[0].tasks', message: 'Track "t": tasks must be an array' },
|
|
55
|
+
]);
|
|
56
|
+
expect(result.summary).not.toMatch(/Validation crashed/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('routes schema errors through validation when YAML syntax is valid', () => {
|
|
60
|
+
const result = compileYamlContent(`
|
|
61
|
+
pipeline:
|
|
62
|
+
name: Missing Track Name
|
|
63
|
+
tracks:
|
|
64
|
+
- id: main
|
|
65
|
+
tasks:
|
|
66
|
+
- id: task
|
|
67
|
+
prompt: hello
|
|
68
|
+
`);
|
|
69
|
+
|
|
70
|
+
expect(result.parseOk).toBe(true);
|
|
71
|
+
expect(result.success).toBe(false);
|
|
72
|
+
expect(result.validation.errors).toContainEqual({
|
|
73
|
+
path: 'tracks[0].name',
|
|
74
|
+
message: 'Track name is required',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('validates pipeline, track, and task permissions shape', () => {
|
|
79
|
+
const result = compileYamlContent(`
|
|
80
|
+
pipeline:
|
|
81
|
+
name: Bad Permissions
|
|
82
|
+
permissions: { read: true, write: "yes", execute: false }
|
|
83
|
+
tracks:
|
|
84
|
+
- id: main
|
|
85
|
+
name: Main
|
|
86
|
+
permissions: { read: true, execute: false }
|
|
87
|
+
tasks:
|
|
88
|
+
- id: task
|
|
89
|
+
prompt: hello
|
|
90
|
+
permissions: nope
|
|
91
|
+
`);
|
|
92
|
+
|
|
93
|
+
expect(result.parseOk).toBe(true);
|
|
94
|
+
expect(result.success).toBe(false);
|
|
95
|
+
expect(result.validation.errors).toContainEqual({
|
|
96
|
+
path: 'permissions.write',
|
|
97
|
+
message: 'permissions.write must be a boolean',
|
|
98
|
+
});
|
|
99
|
+
expect(result.validation.errors).toContainEqual({
|
|
100
|
+
path: 'tracks[0].permissions.write',
|
|
101
|
+
message: 'permissions.write is required',
|
|
102
|
+
});
|
|
103
|
+
expect(result.validation.errors).toContainEqual({
|
|
104
|
+
path: 'tracks[0].tasks[0].permissions',
|
|
105
|
+
message: 'permissions must be an object with read/write/execute booleans',
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
package/src/yaml-compiler.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import yaml from 'js-yaml';
|
|
2
2
|
import { validateRaw } from './validate-raw';
|
|
3
3
|
import type { ValidationError, KnownPluginTypes } from './validate-raw';
|
|
4
4
|
import type { RawPipelineConfig } from './types';
|
|
@@ -27,9 +27,9 @@ export function compileYamlContent(
|
|
|
27
27
|
const timestamp = new Date().toISOString();
|
|
28
28
|
const sourceName = opts.sourceName ?? 'untitled';
|
|
29
29
|
|
|
30
|
-
let
|
|
30
|
+
let doc: unknown;
|
|
31
31
|
try {
|
|
32
|
-
|
|
32
|
+
doc = yaml.load(content);
|
|
33
33
|
} catch (err) {
|
|
34
34
|
return {
|
|
35
35
|
timestamp,
|
|
@@ -41,6 +41,12 @@ export function compileYamlContent(
|
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
const envelopeErrors = validateEnvelope(doc);
|
|
45
|
+
if (envelopeErrors.length > 0) {
|
|
46
|
+
return buildValidationResult(timestamp, sourceName, envelopeErrors);
|
|
47
|
+
}
|
|
48
|
+
const config = (doc as { pipeline: RawPipelineConfig }).pipeline;
|
|
49
|
+
|
|
44
50
|
let errors: ValidationError[];
|
|
45
51
|
try {
|
|
46
52
|
errors = validateRaw(config, opts.knownTypes);
|
|
@@ -55,8 +61,29 @@ export function compileYamlContent(
|
|
|
55
61
|
};
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
return buildValidationResult(timestamp, sourceName, errors);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateEnvelope(doc: unknown): ValidationError[] {
|
|
68
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc) || !('pipeline' in doc)) {
|
|
69
|
+
return [{ path: 'pipeline', message: 'Top-level "pipeline" key is required' }];
|
|
70
|
+
}
|
|
71
|
+
const pipeline = (doc as Record<string, unknown>).pipeline;
|
|
72
|
+
if (!pipeline || typeof pipeline !== 'object' || Array.isArray(pipeline)) {
|
|
73
|
+
return [{ path: 'pipeline', message: 'pipeline must be an object' }];
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildValidationResult(
|
|
79
|
+
timestamp: string,
|
|
80
|
+
sourceName: string,
|
|
81
|
+
diagnostics: readonly ValidationError[],
|
|
82
|
+
): YamlCompileResult {
|
|
83
|
+
const validationErrors = diagnostics.filter(
|
|
84
|
+
(e) => e.severity === 'error' || e.severity == null,
|
|
85
|
+
);
|
|
86
|
+
const validationWarnings = diagnostics.filter((e) => e.severity === 'warning');
|
|
60
87
|
|
|
61
88
|
return {
|
|
62
89
|
timestamp,
|