runspec-node 0.17.1 → 0.21.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.
Files changed (46) hide show
  1. package/dist/cli.js +6 -0
  2. package/dist/cli.js.map +1 -1
  3. package/dist/errors.d.ts +5 -0
  4. package/dist/errors.d.ts.map +1 -1
  5. package/dist/errors.js +44 -0
  6. package/dist/errors.js.map +1 -1
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +3 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/inference.d.ts +6 -0
  12. package/dist/inference.d.ts.map +1 -1
  13. package/dist/inference.js +25 -0
  14. package/dist/inference.js.map +1 -1
  15. package/dist/loader.js +4 -0
  16. package/dist/loader.js.map +1 -1
  17. package/dist/logging_setup.d.ts +29 -1
  18. package/dist/logging_setup.d.ts.map +1 -1
  19. package/dist/logging_setup.js +120 -30
  20. package/dist/logging_setup.js.map +1 -1
  21. package/dist/models.d.ts +4 -0
  22. package/dist/models.d.ts.map +1 -1
  23. package/dist/parser.d.ts +2 -0
  24. package/dist/parser.d.ts.map +1 -1
  25. package/dist/parser.js +13 -3
  26. package/dist/parser.js.map +1 -1
  27. package/dist/types.d.ts.map +1 -1
  28. package/dist/types.js +44 -2
  29. package/dist/types.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/cli.ts +3 -0
  32. package/src/errors.ts +48 -0
  33. package/src/index.ts +1 -0
  34. package/src/inference.ts +25 -0
  35. package/src/loader.ts +4 -0
  36. package/src/logging_setup.ts +137 -30
  37. package/src/models.ts +4 -0
  38. package/src/parser.ts +21 -4
  39. package/src/types.ts +51 -3
  40. package/tests/test_emit_schema.test.ts +48 -0
  41. package/tests/test_inference.test.ts +29 -1
  42. package/tests/test_integration.test.ts +20 -0
  43. package/tests/test_loader.test.ts +33 -0
  44. package/tests/test_parser.test.ts +83 -1
  45. package/tests/test_run_summary.test.ts +99 -0
  46. package/tests/test_types.test.ts +73 -0
@@ -106,6 +106,39 @@ workers = {default = 4, range = [1, 32]}
106
106
  expect(raw.runnables['greet'].args['workers'].range).toEqual([1, 32]);
107
107
  });
108
108
 
109
+ test('normalises pattern / min-length / max-length fields', () => {
110
+ const dir = tmpDir();
111
+ const file = path.join(dir, 'runspec.toml');
112
+ fs.writeFileSync(file, `
113
+ [greet]
114
+ description = "hi"
115
+
116
+ [greet.args]
117
+ slug = {type = "str", pattern = "[a-z]+-[0-9]+", min-length = 3, max-length = 10}
118
+ `);
119
+ const raw = loadRaw(file);
120
+ const slug = raw.runnables['greet'].args['slug'];
121
+ expect(slug.pattern).toBe('[a-z]+-[0-9]+');
122
+ expect(slug.minLength).toBe(3);
123
+ expect(slug.maxLength).toBe(10);
124
+ });
125
+
126
+ test('normalises require-command on a runnable', () => {
127
+ const dir = tmpDir();
128
+ const file = path.join(dir, 'runspec.toml');
129
+ fs.writeFileSync(file, `
130
+ [db]
131
+ require-command = true
132
+
133
+ [db.commands.migrate]
134
+ description = "run migrations"
135
+ `);
136
+ const raw = loadRaw(file);
137
+ expect(raw.runnables['db'].requireCommand).toBe(true);
138
+ // Defaults to false when absent.
139
+ expect(raw.runnables['db'].commands['migrate'].requireCommand).toBe(false);
140
+ });
141
+
109
142
  test('normalises group fields', () => {
110
143
  const dir = tmpDir();
111
144
  const file = path.join(dir, 'runspec.toml');
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as os from 'os';
3
3
  import * as path from 'path';
4
- import { parse } from '../src/parser';
4
+ import { parse, loadSpec } from '../src/parser';
5
5
 
6
6
  function makeTmpConfig(toml: string): string {
7
7
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-parser-test-'));
@@ -157,3 +157,85 @@ description = "Run it"
157
157
  expect(usage.indexOf('<command>')).toBeLessThan(usage.indexOf('-- <extra>'));
158
158
  });
159
159
  });
160
+
161
+ // ── require-command ─────────────────────────────────────────────────────────────
162
+
163
+ describe('require-command', () => {
164
+ const DB_TOML = `
165
+ [db]
166
+ require-command = true
167
+ [db.commands.migrate]
168
+ [db.commands.seed]
169
+ `;
170
+
171
+ test('no command errors and lists commands', () => {
172
+ const configPath = makeTmpConfig(DB_TOML);
173
+ let msg = '';
174
+ try {
175
+ parse({ scriptName: 'db', argv: [], configPath });
176
+ throw new Error('expected parse to throw');
177
+ } catch (e) {
178
+ msg = (e as Error).message;
179
+ }
180
+ expect(msg).toContain('requires a command');
181
+ expect(msg).toContain('migrate');
182
+ expect(msg).toContain('seed');
183
+ });
184
+
185
+ test('valid command passes', () => {
186
+ const configPath = makeTmpConfig(DB_TOML);
187
+ const args = parse({ scriptName: 'db', argv: ['migrate'], configPath });
188
+ expect(args.runspec_command_path).toEqual(['migrate']);
189
+ expect(args.runspec_command).toBe('migrate');
190
+ });
191
+
192
+ test('nested requirement enforced at each depth', () => {
193
+ const toml = `
194
+ [app]
195
+ require-command = true
196
+ [app.commands.db]
197
+ require-command = true
198
+ [app.commands.db.commands.migrate]
199
+ `;
200
+ const configPath = makeTmpConfig(toml);
201
+ // Choosing the intermediate command isn't enough — db itself requires one.
202
+ expect(() => parse({ scriptName: 'app', argv: ['db'], configPath })).toThrow(/requires a command/);
203
+ // Going all the way down satisfies it.
204
+ const args = parse({ scriptName: 'app', argv: ['db', 'migrate'], configPath });
205
+ expect(args.runspec_command_path).toEqual(['db', 'migrate']);
206
+ });
207
+
208
+ test('backward compatible: commands without require-command run at root', () => {
209
+ const toml = `
210
+ [tool]
211
+ [tool.commands.run]
212
+ `;
213
+ const configPath = makeTmpConfig(toml);
214
+ const args = parse({ scriptName: 'tool', argv: [], configPath });
215
+ expect(args.runspec_command_path).toEqual([]);
216
+ });
217
+
218
+ test('loadSpec bypasses enforcement', () => {
219
+ const configPath = makeTmpConfig(DB_TOML);
220
+ const spec = loadSpec({ scriptName: 'db', configPath });
221
+ expect(spec.runspec_command_path).toEqual([]);
222
+ });
223
+
224
+ test('--help still works at a required level', () => {
225
+ const configPath = makeTmpConfig(DB_TOML);
226
+ const output: string[] = [];
227
+ const logSpy = jest.spyOn(console, 'log').mockImplementation((...a) => { output.push(a.join(' ')); });
228
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => { throw new Error('__exit__'); }) as never);
229
+ try {
230
+ parse({ scriptName: 'db', argv: ['--help'], configPath });
231
+ } catch (e) {
232
+ if ((e as Error).message !== '__exit__') throw e;
233
+ } finally {
234
+ logSpy.mockRestore();
235
+ exitSpy.mockRestore();
236
+ }
237
+ const out = output.join('\n');
238
+ expect(out).toContain('Commands (required):');
239
+ expect(out).toContain('migrate');
240
+ });
241
+ });
@@ -6,7 +6,11 @@ import {
6
6
  getLogger,
7
7
  emitRunSummary,
8
8
  _resetForTest,
9
+ _handleUncaught,
10
+ buildExcStructured,
11
+ formatCompactTrace,
9
12
  RUN_SUMMARY_LOGGER,
13
+ EXCEPTION_LOGGER,
10
14
  } from '../src/logging_setup';
11
15
 
12
16
  function tmpDir(): string {
@@ -260,3 +264,98 @@ test('sudo user_target written to audit record', () => {
260
264
  expect(summary.extra.user).toBe('alice');
261
265
  expect(summary.extra.user_target).toBe('root');
262
266
  });
267
+
268
+ // ── uncaught exceptions ────────────────────────────────────────────────────────
269
+
270
+ function readExcRecord(dir: string): Record<string, any> | undefined {
271
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8');
272
+ return content.trim().split('\n').map(l => JSON.parse(l)).find(o => o.logger === EXCEPTION_LOGGER);
273
+ }
274
+
275
+ test('uncaught exception writes a structured record even with summary off', () => {
276
+ const dir = tmpDir();
277
+ configureLogging(makeCfg(dir, { summary: false }));
278
+ const cap = captureStderr();
279
+ _handleUncaught(new TypeError('invalid quality 200'));
280
+ cap.restore();
281
+ const rec = readExcRecord(dir);
282
+ expect(rec).toBeDefined();
283
+ expect(rec!.level).toBe('CRITICAL');
284
+ expect(rec!.exc_structured.type).toBe('TypeError');
285
+ expect(rec!.exc_structured.message).toBe('invalid quality 200');
286
+ expect(Array.isArray(rec!.exc_structured.frames)).toBe(true);
287
+ expect(rec!.exc).toBeDefined(); // full stack string also present
288
+ });
289
+
290
+ test('without --debug the console shows a one-liner, not a traceback', () => {
291
+ const dir = tmpDir();
292
+ configureLogging(makeCfg(dir, { debug: false }));
293
+ const cap = captureStderr();
294
+ _handleUncaught(new Error('boom'));
295
+ cap.restore();
296
+ const joined = cap.lines.join('');
297
+ expect(joined).toContain('ERROR: Error: boom');
298
+ expect(joined).toContain('run with --debug');
299
+ expect(joined).not.toContain('\n at '); // no raw V8 stack dump
300
+ expect(readExcRecord(dir)).toBeDefined();
301
+ });
302
+
303
+ test('with --debug the console shows a compact aligned trace', () => {
304
+ const dir = tmpDir();
305
+ configureLogging(makeCfg(dir, { debug: true }));
306
+ const cap = captureStderr();
307
+ _handleUncaught(new Error('boom'));
308
+ cap.restore();
309
+ const joined = cap.lines.join('');
310
+ expect(joined).toContain('Error: boom');
311
+ expect(joined).not.toContain('run with --debug'); // hint only in quiet mode
312
+ });
313
+
314
+ test('the exception record is not echoed to the console handlers', () => {
315
+ const dir = tmpDir();
316
+ const stdoutLines: string[] = [];
317
+ const stderrLines: string[] = [];
318
+ const o = jest.spyOn(process.stdout, 'write').mockImplementation((c) => { stdoutLines.push(String(c)); return true; });
319
+ const e = jest.spyOn(process.stderr, 'write').mockImplementation((c) => { stderrLines.push(String(c)); return true; });
320
+ configureLogging(makeCfg(dir, { debug: false }));
321
+ _handleUncaught(new Error('boom'));
322
+ o.mockRestore();
323
+ e.mockRestore();
324
+ // The structured JSON record is file-only; only our one-liner hits stderr.
325
+ expect(stdoutLines.join('')).not.toContain('uncaught exception');
326
+ expect(stderrLines.join('')).not.toContain('"logger":"runspec.exception"');
327
+ });
328
+
329
+ test('buildExcStructured parses frames innermost-last with module', () => {
330
+ const err = new Error('x');
331
+ err.stack = [
332
+ 'Error: x',
333
+ ' at inner (/app/deep.js:5:10)',
334
+ ' at outer (/app/main.js:20:3)',
335
+ ].join('\n');
336
+ const es = buildExcStructured(err);
337
+ expect(es.type).toBe('Error');
338
+ expect(es.frames.map(f => f.func)).toEqual(['outer', 'inner']); // innermost last
339
+ expect(es.module).toBe('deep');
340
+ expect(es.frames[0]).toEqual({ func: 'outer', file: '/app/main.js', line: 20, code: null });
341
+ });
342
+
343
+ test('formatCompactTrace drops internal runspec frames but keeps user frames', () => {
344
+ const err = new Error('x');
345
+ // RUNSPEC_PKG_DIR is the dir of the logging_setup module (the package source).
346
+ const pkgDir = path.resolve(__dirname, '..', 'src');
347
+ const frames = [
348
+ { func: 'parse', file: path.join(pkgDir, 'parser.ts'), line: 10, code: null },
349
+ { func: 'main', file: '/app/deploy.js', line: 42, code: null },
350
+ ];
351
+ const out = formatCompactTrace(err, frames);
352
+ expect(out).toContain('deploy.js:42');
353
+ expect(out).not.toContain('parser.ts:10');
354
+ });
355
+
356
+ test('formatCompactTrace falls back to full list when every frame is internal', () => {
357
+ const err = new Error('x');
358
+ const pkgDir = path.resolve(__dirname, '..', 'src');
359
+ const frames = [{ func: 'parse', file: path.join(pkgDir, 'parser.ts'), line: 10, code: null }];
360
+ expect(formatCompactTrace(err, frames)).toContain('parser.ts:10');
361
+ });
@@ -15,6 +15,38 @@ test('coerces number to string', () => {
15
15
  expect(coerce(42, spec({ type: 'str' }))).toBe('42');
16
16
  });
17
17
 
18
+ // ── str validation: pattern / min-length / max-length (str only) ───────────────
19
+
20
+ test('str matches pattern', () => {
21
+ expect(coerce('PROJ-123', spec({ type: 'str', pattern: '[A-Z]+-[0-9]+' }))).toBe('PROJ-123');
22
+ });
23
+
24
+ test('str violates pattern', () => {
25
+ expect(() => coerce('proj-123', spec({ type: 'str', pattern: '[A-Z]+-[0-9]+' }))).toThrow();
26
+ });
27
+
28
+ test('pattern is anchored (fullmatch, not substring)', () => {
29
+ expect(() => coerce('PROJ-123-extra', spec({ type: 'str', pattern: '[A-Z]+-[0-9]+' }))).toThrow();
30
+ });
31
+
32
+ test('anchored pattern wraps top-level alternation correctly', () => {
33
+ // ^(?:a|b)$ accepts only "a" or "b" exactly, not a string ending in "b".
34
+ expect(coerce('a', spec({ type: 'str', pattern: 'a|b' }))).toBe('a');
35
+ expect(() => coerce('xb', spec({ type: 'str', pattern: 'a|b' }))).toThrow();
36
+ });
37
+
38
+ test('str respects minLength', () => {
39
+ expect(() => coerce('ab', spec({ type: 'str', minLength: 3 }))).toThrow();
40
+ });
41
+
42
+ test('str respects maxLength', () => {
43
+ expect(() => coerce('abcdef', spec({ type: 'str', maxLength: 5 }))).toThrow();
44
+ });
45
+
46
+ test('str within length bounds passes', () => {
47
+ expect(coerce('hello', spec({ type: 'str', minLength: 3, maxLength: 10 }))).toBe('hello');
48
+ });
49
+
18
50
  // ── int ───────────────────────────────────────────────────────────────────────
19
51
 
20
52
  test('coerces integer string', () => {
@@ -97,6 +129,47 @@ test('rejects invalid choice', () => {
97
129
  expect(() => coerce('xml', spec({ type: 'choice', options: ['json', 'csv'] }))).toThrow();
98
130
  });
99
131
 
132
+ // ── multiple (per-item coercion) ───────────────────────────────────────────────
133
+
134
+ test('multiple returns a list of coerced items', () => {
135
+ expect(coerce(['a', 'b', 'c'], spec({ type: 'str', multiple: true }))).toEqual(['a', 'b', 'c']);
136
+ });
137
+
138
+ test('multiple int coerces each item', () => {
139
+ expect(coerce(['1', '2', '3'], spec({ type: 'int', multiple: true }))).toEqual([1, 2, 3]);
140
+ });
141
+
142
+ test('multiple applies pattern per item', () => {
143
+ const s = spec({ type: 'str', multiple: true, pattern: '[a-z]+' });
144
+ expect(coerce(['ab', 'cd'], s)).toEqual(['ab', 'cd']);
145
+ });
146
+
147
+ test('multiple wraps a scalar value into a one-item list', () => {
148
+ expect(coerce('solo', spec({ type: 'str', multiple: true }))).toEqual(['solo']);
149
+ });
150
+
151
+ test('multiple collects all failing items with index and value', () => {
152
+ const s = spec({ name: 'tag', type: 'str', multiple: true, pattern: '[a-z]+' });
153
+ let msg = '';
154
+ try {
155
+ coerce(['ab', 'XY', 'z9', 'de'], s);
156
+ } catch (e) {
157
+ msg = (e as Error).message;
158
+ }
159
+ expect(msg).toContain('2 of 4 item(s) failed');
160
+ expect(msg).toContain('item 2 ("XY")');
161
+ expect(msg).toContain('item 3 ("z9")');
162
+ });
163
+
164
+ test('multiple range applies per item', () => {
165
+ const s = spec({ name: 'n', type: 'int', multiple: true, range: [1, 10] });
166
+ expect(() => coerce(['5', '99'], s)).toThrow(/item 2 \("99"\)/);
167
+ });
168
+
169
+ test('rest type is not treated as per-item', () => {
170
+ expect(coerce(['--flag', 'value'], spec({ type: 'rest' }))).toEqual(['--flag', 'value']);
171
+ });
172
+
100
173
  // ── custom types ──────────────────────────────────────────────────────────────
101
174
 
102
175
  test('registerType adds custom coercer', () => {