runspec-node 0.3.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/bin/runspec.js +4 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +384 -0
- package/dist/cli.js.map +1 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +146 -0
- package/dist/errors.js.map +1 -0
- package/dist/finder.d.ts +6 -0
- package/dist/finder.d.ts.map +1 -0
- package/dist/finder.js +91 -0
- package/dist/finder.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/inference.d.ts +10 -0
- package/dist/inference.d.ts.map +1 -0
- package/dist/inference.js +69 -0
- package/dist/inference.js.map +1 -0
- package/dist/loader.d.ts +3 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +142 -0
- package/dist/loader.js.map +1 -0
- package/dist/models.d.ts +57 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +3 -0
- package/dist/models.js.map +1 -0
- package/dist/parser.d.ts +10 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +251 -0
- package/dist/parser.js.map +1 -0
- package/dist/serve.d.ts +2 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +199 -0
- package/dist/serve.js.map +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +121 -0
- package/dist/types.js.map +1 -0
- package/dist/validator.d.ts +5 -0
- package/dist/validator.d.ts.map +1 -0
- package/dist/validator.js +56 -0
- package/dist/validator.js.map +1 -0
- package/jest.config.js +8 -0
- package/package.json +36 -0
- package/src/cli.ts +378 -0
- package/src/errors.ts +126 -0
- package/src/finder.ts +59 -0
- package/src/index.ts +6 -0
- package/src/inference.ts +77 -0
- package/src/loader.ts +120 -0
- package/src/models.ts +61 -0
- package/src/parser.ts +239 -0
- package/src/serve.ts +196 -0
- package/src/types.ts +96 -0
- package/src/validator.ts +64 -0
- package/tests/test_inference.test.ts +153 -0
- package/tests/test_integration.test.ts +197 -0
- package/tests/test_loader.test.ts +169 -0
- package/tests/test_types.test.ts +110 -0
- package/tests/test_validator.test.ts +120 -0
- package/tsconfig.json +18 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import type { ArgSpec } from './models';
|
|
3
|
+
import { formatInvalidChoice } from './errors';
|
|
4
|
+
|
|
5
|
+
export type TypeCoercer = (value: unknown, spec: ArgSpec) => unknown;
|
|
6
|
+
|
|
7
|
+
const registry = new Map<string, TypeCoercer>();
|
|
8
|
+
|
|
9
|
+
export function registerType(name: string, coercer: TypeCoercer): void {
|
|
10
|
+
registry.set(name, coercer);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function coerce(value: unknown, spec: ArgSpec): unknown {
|
|
14
|
+
const typeName = spec.type ?? 'str';
|
|
15
|
+
const coercer = registry.get(typeName);
|
|
16
|
+
if (!coercer) {
|
|
17
|
+
throw new TypeError(
|
|
18
|
+
`Unknown type '${typeName}' for argument '${spec.name}'. Registered types: ${[...registry.keys()].sort().join(', ')}\nRegister custom types with registerType().`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return coercer(value, spec);
|
|
23
|
+
} catch (e) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Cannot coerce value ${JSON.stringify(value)} to type '${typeName}' for argument '--${spec.name ?? '?'}': ${(e as Error).message}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function listTypes(): string[] {
|
|
31
|
+
return [...registry.keys()].sort();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function coerceStr(value: unknown): string {
|
|
35
|
+
return String(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function coerceInt(value: unknown, spec: ArgSpec): number {
|
|
39
|
+
const n = Number(value);
|
|
40
|
+
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
|
41
|
+
throw new Error(`invalid integer: ${JSON.stringify(value)}`);
|
|
42
|
+
}
|
|
43
|
+
checkRange(n, spec);
|
|
44
|
+
return n;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function coerceFloat(value: unknown, spec: ArgSpec): number {
|
|
48
|
+
const n = Number(value);
|
|
49
|
+
if (!Number.isFinite(n)) throw new Error(`invalid number: ${JSON.stringify(value)}`);
|
|
50
|
+
checkRange(n, spec);
|
|
51
|
+
return n;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function coerceBool(value: unknown): boolean {
|
|
55
|
+
if (typeof value === 'boolean') return value;
|
|
56
|
+
if (typeof value === 'string') {
|
|
57
|
+
if (['true', '1', 'yes', 'on'].includes(value.toLowerCase())) return true;
|
|
58
|
+
if (['false', '0', 'no', 'off'].includes(value.toLowerCase())) return false;
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`Cannot interpret ${JSON.stringify(value)} as bool`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function coerceFlag(value: unknown): boolean {
|
|
64
|
+
if (typeof value === 'boolean') return value;
|
|
65
|
+
return Boolean(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function coercePath(value: unknown): string {
|
|
69
|
+
return path.resolve(String(value));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function coerceChoice(value: unknown, spec: ArgSpec): string {
|
|
73
|
+
const v = String(value);
|
|
74
|
+
const options = spec.options ?? [];
|
|
75
|
+
if (options.length > 0 && !options.includes(v)) {
|
|
76
|
+
throw new Error(formatInvalidChoice(v, options, spec.name ?? '?'));
|
|
77
|
+
}
|
|
78
|
+
return v;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function checkRange(value: number, spec: ArgSpec): void {
|
|
82
|
+
if (spec.range) {
|
|
83
|
+
const [min, max] = spec.range;
|
|
84
|
+
if (value < min || value > max) {
|
|
85
|
+
throw new Error(`Value ${value} is out of range [${min}, ${max}]`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
registerType('str', coerceStr);
|
|
91
|
+
registerType('int', coerceInt);
|
|
92
|
+
registerType('float', coerceFloat);
|
|
93
|
+
registerType('bool', coerceBool);
|
|
94
|
+
registerType('flag', coerceFlag);
|
|
95
|
+
registerType('path', coercePath);
|
|
96
|
+
registerType('choice', coerceChoice);
|
package/src/validator.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ArgSpec, GroupSpec } from './models';
|
|
2
|
+
import {
|
|
3
|
+
RunSpecError,
|
|
4
|
+
formatMissingRequired,
|
|
5
|
+
formatGroupExclusive,
|
|
6
|
+
formatGroupInclusive,
|
|
7
|
+
formatGroupAtLeastOne,
|
|
8
|
+
formatGroupExactlyOne,
|
|
9
|
+
formatDeprecated,
|
|
10
|
+
} from './errors';
|
|
11
|
+
|
|
12
|
+
export function validateArgs(parsedValues: Record<string, unknown>, argSpecs: Record<string, ArgSpec>): string[] {
|
|
13
|
+
const errors: string[] = [];
|
|
14
|
+
|
|
15
|
+
for (const [name, spec] of Object.entries(argSpecs)) {
|
|
16
|
+
const value = parsedValues[name];
|
|
17
|
+
const missing = value === null || value === undefined;
|
|
18
|
+
|
|
19
|
+
if (spec.required && missing) {
|
|
20
|
+
errors.push(formatMissingRequired(name, spec as unknown as Record<string, unknown>));
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!missing && spec.deprecated) {
|
|
25
|
+
process.stderr.write(formatDeprecated(name, spec.deprecated) + '\n');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return errors;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateGroups(parsedValues: Record<string, unknown>, groupSpecs: Record<string, GroupSpec>): string[] {
|
|
33
|
+
const errors: string[] = [];
|
|
34
|
+
|
|
35
|
+
for (const [groupName, group] of Object.entries(groupSpecs)) {
|
|
36
|
+
const groupArgs = group.args ?? [];
|
|
37
|
+
const provided = groupArgs.filter((a) => parsedValues[a] !== null && parsedValues[a] !== undefined);
|
|
38
|
+
|
|
39
|
+
if (group.exclusive && provided.length > 1) {
|
|
40
|
+
errors.push(formatGroupExclusive(groupName, provided));
|
|
41
|
+
} else if (group.inclusive && provided.length > 0 && provided.length < groupArgs.length) {
|
|
42
|
+
errors.push(formatGroupInclusive(groupName, groupArgs.filter((a) => !provided.includes(a))));
|
|
43
|
+
} else if (group.atLeastOne && provided.length === 0) {
|
|
44
|
+
errors.push(formatGroupAtLeastOne(groupName, groupArgs));
|
|
45
|
+
} else if (group.exactlyOne && provided.length !== 1) {
|
|
46
|
+
errors.push(formatGroupExactlyOne(groupName, groupArgs, provided));
|
|
47
|
+
} else if (group.condition) {
|
|
48
|
+
const condVal = parsedValues[group.condition];
|
|
49
|
+
if (condVal !== null && condVal !== undefined) {
|
|
50
|
+
const requires = group.requires ?? [];
|
|
51
|
+
const missing = requires.filter((a) => parsedValues[a] === null || parsedValues[a] === undefined);
|
|
52
|
+
if (missing.length > 0) errors.push(formatGroupInclusive(groupName, missing));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return errors;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function raiseIfErrors(errorMessages: string[]): void {
|
|
61
|
+
if (errorMessages.length > 0) {
|
|
62
|
+
throw new RunSpecError(errorMessages.join('\n\n'));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { inferArg, inferScript, effectiveAutonomy, isMoreRestrictive } from '../src/inference';
|
|
2
|
+
import { RunSpecError } from '../src/errors';
|
|
3
|
+
import type { ArgSpec, ScriptSpec } from '../src/models';
|
|
4
|
+
|
|
5
|
+
function makeArg(overrides: Partial<ArgSpec> = {}): ArgSpec {
|
|
6
|
+
return { name: 'test', args: [], ...overrides } as unknown as ArgSpec;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// ── inferArg — type inference ─────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
test('infers flag from boolean default', () => {
|
|
12
|
+
const result = inferArg(makeArg({ default: false }));
|
|
13
|
+
expect(result.type).toBe('flag');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('infers flag from true default', () => {
|
|
17
|
+
const result = inferArg(makeArg({ default: true }));
|
|
18
|
+
expect(result.type).toBe('flag');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('infers int from integer default', () => {
|
|
22
|
+
const result = inferArg(makeArg({ default: 42 }));
|
|
23
|
+
expect(result.type).toBe('int');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('infers float from float default', () => {
|
|
27
|
+
const result = inferArg(makeArg({ default: 3.14 }));
|
|
28
|
+
expect(result.type).toBe('float');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('infers str from string default', () => {
|
|
32
|
+
const result = inferArg(makeArg({ default: 'json' }));
|
|
33
|
+
expect(result.type).toBe('str');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('infers choice when options present', () => {
|
|
37
|
+
const result = inferArg(makeArg({ options: ['a', 'b'] }));
|
|
38
|
+
expect(result.type).toBe('choice');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('choice wins over type inference when options present', () => {
|
|
42
|
+
const result = inferArg(makeArg({ options: ['a', 'b'], default: 'a' }));
|
|
43
|
+
expect(result.type).toBe('choice');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('defaults to str when no clues', () => {
|
|
47
|
+
const result = inferArg(makeArg({}));
|
|
48
|
+
expect(result.type).toBe('str');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('preserves explicit type', () => {
|
|
52
|
+
const result = inferArg(makeArg({ type: 'path' }));
|
|
53
|
+
expect(result.type).toBe('path');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── inferArg — required inference ────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
test('required=true when no default and not flag', () => {
|
|
59
|
+
const result = inferArg(makeArg({ name: 'input' }));
|
|
60
|
+
expect(result.required).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('required=false when default present', () => {
|
|
64
|
+
const result = inferArg(makeArg({ default: 'hello' }));
|
|
65
|
+
expect(result.required).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('required=false for flag with no default', () => {
|
|
69
|
+
const result = inferArg(makeArg({ type: 'flag' }));
|
|
70
|
+
expect(result.required).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('required=false for flag with false default', () => {
|
|
74
|
+
const result = inferArg(makeArg({ default: false }));
|
|
75
|
+
expect(result.required).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('preserves explicit required=false', () => {
|
|
79
|
+
const result = inferArg(makeArg({ required: false }));
|
|
80
|
+
expect(result.required).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ── inferArg — errors ─────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
test('throws when type=choice but no options', () => {
|
|
86
|
+
expect(() => inferArg(makeArg({ type: 'choice' }))).toThrow(RunSpecError);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ── inferScript ───────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
test('fills autonomy from config default', () => {
|
|
92
|
+
const script: ScriptSpec = { name: 'test', args: {}, groups: {}, commands: {} };
|
|
93
|
+
const result = inferScript(script, 'confirm');
|
|
94
|
+
expect(result.autonomy).toBe('confirm');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('preserves explicit autonomy', () => {
|
|
98
|
+
const script: ScriptSpec = { name: 'test', autonomy: 'autonomous', args: {}, groups: {}, commands: {} };
|
|
99
|
+
const result = inferScript(script, 'confirm');
|
|
100
|
+
expect(result.autonomy).toBe('autonomous');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('infers all args', () => {
|
|
104
|
+
const script: ScriptSpec = {
|
|
105
|
+
name: 'test',
|
|
106
|
+
args: { verbose: { name: 'verbose', default: false }, workers: { name: 'workers', default: 4 } },
|
|
107
|
+
groups: {},
|
|
108
|
+
commands: {},
|
|
109
|
+
};
|
|
110
|
+
const result = inferScript(script, 'confirm');
|
|
111
|
+
expect(result.args['verbose'].type).toBe('flag');
|
|
112
|
+
expect(result.args['workers'].type).toBe('int');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('recurses into subcommands', () => {
|
|
116
|
+
const script: ScriptSpec = {
|
|
117
|
+
name: 'test',
|
|
118
|
+
args: {},
|
|
119
|
+
groups: {},
|
|
120
|
+
commands: {
|
|
121
|
+
run: { name: 'run', args: { input: { name: 'input', type: 'path' } }, groups: {}, commands: {} },
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
const result = inferScript(script, 'confirm');
|
|
125
|
+
expect(result.commands['run'].autonomy).toBe('confirm');
|
|
126
|
+
expect(result.commands['run'].args['input'].required).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ── effectiveAutonomy ─────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
test('returns script autonomy when no arg overrides', () => {
|
|
132
|
+
expect(effectiveAutonomy('confirm', {}, {})).toBe('confirm');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('escalates to more restrictive arg autonomy', () => {
|
|
136
|
+
const argSpecs = { apiKey: { name: 'api-key', autonomy: 'manual' } };
|
|
137
|
+
expect(effectiveAutonomy('confirm', { apiKey: 'abc' }, argSpecs)).toBe('manual');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('does not de-escalate', () => {
|
|
141
|
+
const argSpecs = { verbose: { name: 'verbose', autonomy: 'autonomous' } };
|
|
142
|
+
expect(effectiveAutonomy('confirm', { verbose: true }, argSpecs)).toBe('confirm');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ── isMoreRestrictive ─────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
test('manual is more restrictive than confirm', () => {
|
|
148
|
+
expect(isMoreRestrictive('manual', 'confirm')).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('autonomous is not more restrictive than confirm', () => {
|
|
152
|
+
expect(isMoreRestrictive('autonomous', 'confirm')).toBe(false);
|
|
153
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import { loadRaw } from '../src/loader';
|
|
3
|
+
import { inferArg, inferScript } from '../src/inference';
|
|
4
|
+
import { RunSpecError } from '../src/errors';
|
|
5
|
+
|
|
6
|
+
const FIXTURES = path.resolve(__dirname, '../../../tests/integration/fixtures');
|
|
7
|
+
const SIMPLE = path.join(FIXTURES, 'simple.toml');
|
|
8
|
+
const COMPLEX = path.join(FIXTURES, 'complex.toml');
|
|
9
|
+
|
|
10
|
+
// ── simple.toml ───────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
describe('simple.toml', () => {
|
|
13
|
+
test('loads config section', () => {
|
|
14
|
+
const raw = loadRaw(SIMPLE, 'runspec');
|
|
15
|
+
expect(raw.config.autonomyDefault).toBe('confirm');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('greet runnable present', () => {
|
|
19
|
+
const raw = loadRaw(SIMPLE, 'runspec');
|
|
20
|
+
expect(raw.runnables['greet']).toBeDefined();
|
|
21
|
+
expect(raw.runnables['greet'].description).toBe('Greet someone from the command line');
|
|
22
|
+
expect(raw.runnables['greet'].autonomy).toBe('autonomous');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('greet args: name is str and required', () => {
|
|
26
|
+
const raw = loadRaw(SIMPLE, 'runspec');
|
|
27
|
+
const inferred = inferScript(raw.runnables['greet'], raw.config.autonomyDefault);
|
|
28
|
+
expect(inferred.args['name'].type).toBe('str');
|
|
29
|
+
expect(inferred.args['name'].required).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('greet args: loud inferred as flag', () => {
|
|
33
|
+
const raw = loadRaw(SIMPLE, 'runspec');
|
|
34
|
+
const inferred = inferScript(raw.runnables['greet'], raw.config.autonomyDefault);
|
|
35
|
+
expect(inferred.args['loud'].type).toBe('flag');
|
|
36
|
+
expect(inferred.args['loud'].required).toBe(false);
|
|
37
|
+
expect(inferred.args['loud'].default).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('greet args: times inferred as int', () => {
|
|
41
|
+
const raw = loadRaw(SIMPLE, 'runspec');
|
|
42
|
+
const inferred = inferScript(raw.runnables['greet'], raw.config.autonomyDefault);
|
|
43
|
+
expect(inferred.args['times'].type).toBe('int');
|
|
44
|
+
expect(inferred.args['times'].default).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ── complex.toml ──────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe('complex.toml', () => {
|
|
51
|
+
test('loads config section', () => {
|
|
52
|
+
const raw = loadRaw(COMPLEX, 'runspec');
|
|
53
|
+
expect(raw.config.autonomyDefault).toBe('confirm');
|
|
54
|
+
expect(raw.config.lang).toBe('python');
|
|
55
|
+
expect(raw.config.version).toBe('1');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('pipeline runnable present', () => {
|
|
59
|
+
const raw = loadRaw(COMPLEX, 'runspec');
|
|
60
|
+
expect(raw.runnables['pipeline']).toBeDefined();
|
|
61
|
+
expect(raw.runnables['pipeline'].description).toBe('Process and validate data pipeline files');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('pipeline has run and validate subcommands', () => {
|
|
65
|
+
const raw = loadRaw(COMPLEX, 'runspec');
|
|
66
|
+
const cmds = raw.runnables['pipeline'].commands;
|
|
67
|
+
expect(cmds['run']).toBeDefined();
|
|
68
|
+
expect(cmds['validate']).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('run subcommand: input is path and required', () => {
|
|
72
|
+
const raw = loadRaw(COMPLEX, 'runspec');
|
|
73
|
+
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
74
|
+
const run = inferred.commands['run'];
|
|
75
|
+
expect(run.args['input'].type).toBe('path');
|
|
76
|
+
expect(run.args['input'].required).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('run subcommand: format is choice with default', () => {
|
|
80
|
+
const raw = loadRaw(COMPLEX, 'runspec');
|
|
81
|
+
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
82
|
+
const run = inferred.commands['run'];
|
|
83
|
+
expect(run.args['format'].type).toBe('choice');
|
|
84
|
+
expect(run.args['format'].options).toEqual(['json', 'csv', 'parquet']);
|
|
85
|
+
expect(run.args['format'].default).toBe('json');
|
|
86
|
+
expect(run.args['format'].required).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('run subcommand: workers inferred as int with range', () => {
|
|
90
|
+
const raw = loadRaw(COMPLEX, 'runspec');
|
|
91
|
+
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
92
|
+
const run = inferred.commands['run'];
|
|
93
|
+
expect(run.args['workers'].type).toBe('int');
|
|
94
|
+
expect(run.args['workers'].default).toBe(4);
|
|
95
|
+
expect(run.args['workers'].range).toEqual([1, 32]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('run subcommand: dry-run inferred as flag', () => {
|
|
99
|
+
const raw = loadRaw(COMPLEX, 'runspec');
|
|
100
|
+
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
101
|
+
const run = inferred.commands['run'];
|
|
102
|
+
expect(run.args['dry-run'].type).toBe('flag');
|
|
103
|
+
expect(run.args['dry-run'].default).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('run subcommand: tag is multiple', () => {
|
|
107
|
+
const raw = loadRaw(COMPLEX, 'runspec');
|
|
108
|
+
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
109
|
+
const run = inferred.commands['run'];
|
|
110
|
+
expect(run.args['tag'].multiple).toBe(true);
|
|
111
|
+
expect(run.args['tag'].type).toBe('str');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('run subcommand: fields has delimiter', () => {
|
|
115
|
+
const raw = loadRaw(COMPLEX, 'runspec');
|
|
116
|
+
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
117
|
+
const run = inferred.commands['run'];
|
|
118
|
+
expect(run.args['fields'].delimiter).toBe(',');
|
|
119
|
+
expect(run.args['fields'].multiple).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('run subcommand: api-key has env and autonomy', () => {
|
|
123
|
+
const raw = loadRaw(COMPLEX, 'runspec');
|
|
124
|
+
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
125
|
+
const run = inferred.commands['run'];
|
|
126
|
+
expect(run.args['api-key'].env).toBe('PIPELINE_API_KEY');
|
|
127
|
+
expect(run.args['api-key'].autonomy).toBe('manual');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('run subcommand: verbose has short flag', () => {
|
|
131
|
+
const raw = loadRaw(COMPLEX, 'runspec');
|
|
132
|
+
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
133
|
+
const run = inferred.commands['run'];
|
|
134
|
+
expect(run.args['verbose'].short).toBe('-v');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('run subcommand: threads has deprecated field', () => {
|
|
138
|
+
const raw = loadRaw(COMPLEX, 'runspec');
|
|
139
|
+
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
140
|
+
const run = inferred.commands['run'];
|
|
141
|
+
expect(run.args['threads'].deprecated).toBe('use --workers instead');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('run subcommand: groups defined', () => {
|
|
145
|
+
const raw = loadRaw(COMPLEX, 'runspec');
|
|
146
|
+
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
147
|
+
const run = inferred.commands['run'];
|
|
148
|
+
expect(run.groups['input-format']).toBeDefined();
|
|
149
|
+
expect(run.groups['input-format'].exclusive).toBe(true);
|
|
150
|
+
expect(run.groups['api-auth']).toBeDefined();
|
|
151
|
+
expect(run.groups['api-auth'].inclusive).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('validate subcommand: is autonomous', () => {
|
|
155
|
+
const raw = loadRaw(COMPLEX, 'runspec');
|
|
156
|
+
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
157
|
+
const validate = inferred.commands['validate'];
|
|
158
|
+
expect(validate.autonomy).toBe('autonomous');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('validate subcommand: format is choice', () => {
|
|
162
|
+
const raw = loadRaw(COMPLEX, 'runspec');
|
|
163
|
+
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
164
|
+
const validate = inferred.commands['validate'];
|
|
165
|
+
expect(validate.args['format'].type).toBe('choice');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('run subcommand: autonomy-reason preserved', () => {
|
|
169
|
+
const raw = loadRaw(COMPLEX, 'runspec');
|
|
170
|
+
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
171
|
+
const run = inferred.commands['run'];
|
|
172
|
+
expect(run.autonomyReason).toBe('Writes output files and may call external APIs');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ── cross-fixture: inference rules ────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
test('bool checked before int in inference (bool is not int)', () => {
|
|
179
|
+
const arg = inferArg({ name: 'verbose', default: false } as any);
|
|
180
|
+
expect(arg.type).toBe('flag');
|
|
181
|
+
expect(typeof arg.default).toBe('boolean');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('type=path with no default is required', () => {
|
|
185
|
+
const arg = inferArg({ name: 'input', type: 'path' } as any);
|
|
186
|
+
expect(arg.required).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('choice type with options is not required when default present', () => {
|
|
190
|
+
const arg = inferArg({ name: 'format', options: ['json', 'csv'], default: 'json' } as any);
|
|
191
|
+
expect(arg.required).toBe(false);
|
|
192
|
+
expect(arg.type).toBe('choice');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('choice type with no options throws', () => {
|
|
196
|
+
expect(() => inferArg({ name: 'fmt', type: 'choice' } as any)).toThrow(RunSpecError);
|
|
197
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import { loadRaw } from '../src/loader';
|
|
5
|
+
|
|
6
|
+
function tmpDir(): string {
|
|
7
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-test-'));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
// tmp dirs cleaned up by OS
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// ── runspec.toml format ───────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
test('loads simple runspec.toml', () => {
|
|
17
|
+
const dir = tmpDir();
|
|
18
|
+
const file = path.join(dir, 'runspec.toml');
|
|
19
|
+
fs.writeFileSync(file, `
|
|
20
|
+
[greet]
|
|
21
|
+
description = "Greet someone"
|
|
22
|
+
autonomy = "autonomous"
|
|
23
|
+
|
|
24
|
+
[greet.args]
|
|
25
|
+
name = {type = "str"}
|
|
26
|
+
loud = {default = false}
|
|
27
|
+
`);
|
|
28
|
+
const raw = loadRaw(file, 'runspec');
|
|
29
|
+
expect(raw.runnables['greet']).toBeDefined();
|
|
30
|
+
expect(raw.runnables['greet'].description).toBe('Greet someone');
|
|
31
|
+
expect(raw.runnables['greet'].args['name'].type).toBe('str');
|
|
32
|
+
expect(raw.runnables['greet'].args['loud'].default).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('normalises hyphenated field names', () => {
|
|
36
|
+
const dir = tmpDir();
|
|
37
|
+
const file = path.join(dir, 'runspec.toml');
|
|
38
|
+
fs.writeFileSync(file, `
|
|
39
|
+
[deploy]
|
|
40
|
+
autonomy-reason = "Irreversible"
|
|
41
|
+
`);
|
|
42
|
+
const raw = loadRaw(file, 'runspec');
|
|
43
|
+
expect(raw.runnables['deploy'].autonomyReason).toBe('Irreversible');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('parses config section', () => {
|
|
47
|
+
const dir = tmpDir();
|
|
48
|
+
const file = path.join(dir, 'runspec.toml');
|
|
49
|
+
fs.writeFileSync(file, `
|
|
50
|
+
[config]
|
|
51
|
+
autonomy-default = "autonomous"
|
|
52
|
+
version = "1"
|
|
53
|
+
|
|
54
|
+
[greet]
|
|
55
|
+
description = "hi"
|
|
56
|
+
`);
|
|
57
|
+
const raw = loadRaw(file, 'runspec');
|
|
58
|
+
expect(raw.config.autonomyDefault).toBe('autonomous');
|
|
59
|
+
expect(raw.config.version).toBe('1');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('config excluded from runnables', () => {
|
|
63
|
+
const dir = tmpDir();
|
|
64
|
+
const file = path.join(dir, 'runspec.toml');
|
|
65
|
+
fs.writeFileSync(file, `
|
|
66
|
+
[config]
|
|
67
|
+
autonomy-default = "confirm"
|
|
68
|
+
|
|
69
|
+
[greet]
|
|
70
|
+
description = "hi"
|
|
71
|
+
`);
|
|
72
|
+
const raw = loadRaw(file, 'runspec');
|
|
73
|
+
expect('config' in raw.runnables).toBe(false);
|
|
74
|
+
expect('greet' in raw.runnables).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── pyproject.toml format ─────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
test('loads pyproject.toml format', () => {
|
|
80
|
+
const dir = tmpDir();
|
|
81
|
+
const file = path.join(dir, 'pyproject.toml');
|
|
82
|
+
fs.writeFileSync(file, `
|
|
83
|
+
[project]
|
|
84
|
+
name = "myproject"
|
|
85
|
+
|
|
86
|
+
[tool.runspec.greet]
|
|
87
|
+
description = "Greet"
|
|
88
|
+
autonomy = "confirm"
|
|
89
|
+
|
|
90
|
+
[tool.runspec.greet.args]
|
|
91
|
+
name = {type = "str"}
|
|
92
|
+
`);
|
|
93
|
+
const raw = loadRaw(file, 'pyproject');
|
|
94
|
+
expect(raw.runnables['greet']).toBeDefined();
|
|
95
|
+
expect(raw.runnables['greet'].args['name'].type).toBe('str');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('reads entry points from pyproject.toml', () => {
|
|
99
|
+
const dir = tmpDir();
|
|
100
|
+
const file = path.join(dir, 'pyproject.toml');
|
|
101
|
+
fs.writeFileSync(file, `
|
|
102
|
+
[project.scripts]
|
|
103
|
+
greet = "myapp.greet:main"
|
|
104
|
+
|
|
105
|
+
[tool.runspec.greet]
|
|
106
|
+
description = "Greet"
|
|
107
|
+
`);
|
|
108
|
+
const raw = loadRaw(file, 'pyproject');
|
|
109
|
+
expect(raw.entryPoints['greet']).toBe('myapp.greet:main');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── arg normalisation ─────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
test('normalises bare value shorthand', () => {
|
|
115
|
+
const dir = tmpDir();
|
|
116
|
+
const file = path.join(dir, 'runspec.toml');
|
|
117
|
+
fs.writeFileSync(file, `
|
|
118
|
+
[greet]
|
|
119
|
+
description = "hi"
|
|
120
|
+
|
|
121
|
+
[greet.args]
|
|
122
|
+
loud = false
|
|
123
|
+
times = 1
|
|
124
|
+
`);
|
|
125
|
+
const raw = loadRaw(file, 'runspec');
|
|
126
|
+
expect(raw.runnables['greet'].args['loud'].default).toBe(false);
|
|
127
|
+
expect(raw.runnables['greet'].args['times'].default).toBe(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('normalises range field', () => {
|
|
131
|
+
const dir = tmpDir();
|
|
132
|
+
const file = path.join(dir, 'runspec.toml');
|
|
133
|
+
fs.writeFileSync(file, `
|
|
134
|
+
[greet]
|
|
135
|
+
description = "hi"
|
|
136
|
+
|
|
137
|
+
[greet.args]
|
|
138
|
+
workers = {default = 4, range = [1, 32]}
|
|
139
|
+
`);
|
|
140
|
+
const raw = loadRaw(file, 'runspec');
|
|
141
|
+
expect(raw.runnables['greet'].args['workers'].range).toEqual([1, 32]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('normalises group fields', () => {
|
|
145
|
+
const dir = tmpDir();
|
|
146
|
+
const file = path.join(dir, 'runspec.toml');
|
|
147
|
+
fs.writeFileSync(file, `
|
|
148
|
+
[pipeline]
|
|
149
|
+
description = "hi"
|
|
150
|
+
|
|
151
|
+
[pipeline.groups.formats]
|
|
152
|
+
exclusive = true
|
|
153
|
+
args = ["json", "csv"]
|
|
154
|
+
`);
|
|
155
|
+
const raw = loadRaw(file, 'runspec');
|
|
156
|
+
const group = raw.runnables['pipeline'].groups['formats'];
|
|
157
|
+
expect(group.exclusive).toBe(true);
|
|
158
|
+
expect(group.args).toEqual(['json', 'csv']);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── defaults ──────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
test('autonomy-default falls back to confirm', () => {
|
|
164
|
+
const dir = tmpDir();
|
|
165
|
+
const file = path.join(dir, 'runspec.toml');
|
|
166
|
+
fs.writeFileSync(file, `[greet]\ndescription = "hi"\n`);
|
|
167
|
+
const raw = loadRaw(file, 'runspec');
|
|
168
|
+
expect(raw.config.autonomyDefault).toBe('confirm');
|
|
169
|
+
});
|