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
package/src/errors.ts CHANGED
@@ -41,6 +41,54 @@ export function formatOutOfRange(value: number, range: [number, number], name: s
41
41
  ].join('\n');
42
42
  }
43
43
 
44
+ export function formatInvalidPattern(value: string, pattern: string, name: string): string {
45
+ return [
46
+ `✗ Invalid value for --${name}: ${JSON.stringify(value)}`,
47
+ ` Expected: a value matching pattern ${JSON.stringify(pattern)}`,
48
+ ` Got: ${JSON.stringify(value)}`,
49
+ ].join('\n');
50
+ }
51
+
52
+ export function formatTooShort(value: string, minLength: number, name: string): string {
53
+ return [
54
+ `✗ Value too short for --${name}: ${JSON.stringify(value)}`,
55
+ ` Expected: at least ${minLength} character${minLength === 1 ? '' : 's'}`,
56
+ ` Got: ${value.length}`,
57
+ ].join('\n');
58
+ }
59
+
60
+ export function formatTooLong(value: string, maxLength: number, name: string): string {
61
+ return [
62
+ `✗ Value too long for --${name}: ${JSON.stringify(value)}`,
63
+ ` Expected: at most ${maxLength} character${maxLength === 1 ? '' : 's'}`,
64
+ ` Got: ${value.length}`,
65
+ ].join('\n');
66
+ }
67
+
68
+ export function formatInvalidItems(
69
+ name: string,
70
+ total: number,
71
+ failures: Array<[number, unknown, string]>,
72
+ ): string {
73
+ const header = `--${name}: ${failures.length} of ${total} item(s) failed validation:`;
74
+ const blocks = failures.map(([index, value, reason]) => {
75
+ const detail = reason
76
+ .split('\n')
77
+ .map((line) => ' ' + line)
78
+ .join('\n');
79
+ return ` • item ${index} (${JSON.stringify(value)}):\n${detail}`;
80
+ });
81
+ return header + '\n\n' + blocks.join('\n\n');
82
+ }
83
+
84
+ export function formatMissingCommand(runnablePath: string, availableCommands: string[]): string {
85
+ return [
86
+ `✗ '${runnablePath}' requires a command.`,
87
+ ` Available commands: ${availableCommands.join(', ')}`,
88
+ `\n Run '${runnablePath} <command> --help' for details.`,
89
+ ].join('\n');
90
+ }
91
+
44
92
  export function formatUnknownArg(name: string, knownArgs: string[]): string {
45
93
  const lines = [
46
94
  `✗ Unknown argument: --${name}`,
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { parse, loadSpec } from './parser';
2
2
  export { registerType, listTypes } from './types';
3
+ export { tsTypeOf } from './inference';
3
4
  export { RunSpecError, MissingRequiredArg, InvalidChoice, OutOfRange, UnknownArg, GroupViolation, AutonomyViolation } from './errors';
4
5
  export { findConfig } from './finder';
5
6
  export { loadRaw } from './loader';
package/src/inference.ts CHANGED
@@ -4,6 +4,31 @@ import { RunSpecError } from './errors';
4
4
  export const AUTONOMY_LEVELS = ['autonomous', 'confirm', 'supervised', 'manual'] as const;
5
5
  export const AUTONOMY_RANK = Object.fromEntries(AUTONOMY_LEVELS.map((l, i) => [l, i]));
6
6
 
7
+ // Item-type name → its TypeScript type. Mirrors the JSON-Schema type map used
8
+ // by emit; custom (register_type) names fall back to `unknown`.
9
+ const TS_TYPE_NAMES: Record<string, string> = {
10
+ str: 'string',
11
+ int: 'number',
12
+ float: 'number',
13
+ bool: 'boolean',
14
+ flag: 'boolean',
15
+ path: 'string',
16
+ choice: 'string',
17
+ rest: 'string[]',
18
+ };
19
+
20
+ /** The TypeScript type of an arg's parsed value: the item type, suffixed `[]`
21
+ * when the arg is `multiple` (`rest` is always `string[]`). `arg.type` itself
22
+ * stays the item type — it drives per-item coercion. Pure helper, computed on
23
+ * demand from a (typically inferred) ArgSpec; custom registered types fall
24
+ * back to `"unknown"`. e.g. `tsTypeOf({type:"str", multiple:true})` → `"string[]"`. */
25
+ export function tsTypeOf(arg: ArgSpec): string {
26
+ const type = arg.type ?? 'str';
27
+ if (type === 'rest') return 'string[]';
28
+ const base = TS_TYPE_NAMES[type] ?? 'unknown';
29
+ return arg.multiple ? `${base}[]` : base;
30
+ }
31
+
7
32
  export function inferArg(raw: ArgSpec): ArgSpec {
8
33
  const result = { ...raw };
9
34
  const def = result.default;
package/src/loader.ts CHANGED
@@ -58,6 +58,7 @@ function normaliseScript(name: string, raw: Record<string, unknown>): ScriptSpec
58
58
  autonomy: raw['autonomy'] as string | undefined,
59
59
  autonomyReason: raw['autonomy-reason'] as string | undefined,
60
60
  output: raw['output'] as string | undefined,
61
+ requireCommand: (raw['require-command'] as boolean | undefined) ?? false,
61
62
  args: normaliseArgs((raw['args'] ?? {}) as Record<string, unknown>),
62
63
  groups: normaliseGroups((raw['groups'] ?? {}) as Record<string, unknown>),
63
64
  commands: normaliseCommands((raw['commands'] ?? {}) as Record<string, Record<string, unknown>>),
@@ -84,6 +85,9 @@ function normaliseArg(name: string, raw: Record<string, unknown>): ArgSpec {
84
85
  description: raw['description'] as string | undefined,
85
86
  options: raw['options'] as string[] | undefined,
86
87
  range: raw['range'] as [number, number] | undefined,
88
+ pattern: raw['pattern'] as string | undefined,
89
+ minLength: raw['min-length'] as number | undefined,
90
+ maxLength: raw['max-length'] as number | undefined,
87
91
  multiple: (raw['multiple'] as boolean | undefined) ?? false,
88
92
  delimiter: raw['delimiter'] as string | undefined,
89
93
  short: raw['short'] as string | undefined,
@@ -16,6 +16,16 @@ const _loggers = new Map<string, Logger>();
16
16
  const _handlers: Handler[] = [];
17
17
 
18
18
  const RUN_SUMMARY_LOGGER = 'runspec.runsummary';
19
+ // Uncaught exceptions are emitted on this dedicated logger so the file handler
20
+ // records them while the console handlers drop them by name — console display is
21
+ // handled explicitly in _handleUncaught (debug-gated).
22
+ const EXCEPTION_LOGGER = 'runspec.exception';
23
+
24
+ // Directory of the installed runspec-node package — used to filter internal
25
+ // library frames out of the *displayed* compact trace (full trace still hits the file).
26
+ const RUNSPEC_PKG_DIR = __dirname;
27
+
28
+ let _debug = false; // mirrors the --debug flag; read by _handleUncaught for console rendering
19
29
 
20
30
  interface CapturedException {
21
31
  type: string;
@@ -23,6 +33,20 @@ interface CapturedException {
23
33
  traceback: string;
24
34
  }
25
35
 
36
+ interface ExcFrame {
37
+ func: string;
38
+ file: string;
39
+ line: number | null;
40
+ code: string | null;
41
+ }
42
+
43
+ interface ExcStructured {
44
+ type: string;
45
+ message: string;
46
+ module: string | null;
47
+ frames: ExcFrame[];
48
+ }
49
+
26
50
  interface SummaryState {
27
51
  counter: RunSummaryCounter;
28
52
  start: bigint;
@@ -83,6 +107,7 @@ interface LogRecord {
83
107
  message: string;
84
108
  error?: Error;
85
109
  extra?: Record<string, unknown>;
110
+ excStructured?: Record<string, unknown>;
86
111
  }
87
112
 
88
113
  interface Handler {
@@ -145,6 +170,7 @@ function formatJson(record: LogRecord): string {
145
170
  message: record.message,
146
171
  };
147
172
  if (record.error) obj['exc'] = record.error.stack ?? record.error.message;
173
+ if (record.excStructured) obj['exc_structured'] = record.excStructured;
148
174
  if (record.extra) obj['extra'] = record.extra;
149
175
  return JSON.stringify(obj);
150
176
  }
@@ -183,7 +209,7 @@ class StdoutHandler implements Handler {
183
209
 
184
210
  emit(record: LogRecord): void {
185
211
  if (record.levelNum >= 30) return; // WARNING+ belongs on stderr
186
- if (record.loggerName === RUN_SUMMARY_LOGGER) return;
212
+ if (record.loggerName === RUN_SUMMARY_LOGGER || record.loggerName === EXCEPTION_LOGGER) return;
187
213
  try {
188
214
  process.stdout.write(formatConsole(record, this.showTracebacks) + '\n');
189
215
  } catch {
@@ -204,7 +230,7 @@ class StderrHandler implements Handler {
204
230
 
205
231
  emit(record: LogRecord): void {
206
232
  if (record.levelNum < 30) return;
207
- if (record.loggerName === RUN_SUMMARY_LOGGER) return;
233
+ if (record.loggerName === RUN_SUMMARY_LOGGER || record.loggerName === EXCEPTION_LOGGER) return;
208
234
  try {
209
235
  process.stderr.write(formatConsole(record, this.showTracebacks) + '\n');
210
236
  } catch {
@@ -226,8 +252,8 @@ class RunSummaryCounter implements Handler {
226
252
  };
227
253
 
228
254
  emit(record: LogRecord): void {
229
- // Don't count the summary record itself.
230
- if (record.loggerName === RUN_SUMMARY_LOGGER) return;
255
+ // Don't count runspec's own bookkeeping records (summary + uncaught-exception).
256
+ if (record.loggerName === RUN_SUMMARY_LOGGER || record.loggerName === EXCEPTION_LOGGER) return;
231
257
  const label = LEVEL_LABEL[record.levelNum];
232
258
  if (label && label in this.counts) {
233
259
  this.counts[label]++;
@@ -462,6 +488,99 @@ export function emitRunSummary(): void {
462
488
  }
463
489
  }
464
490
 
491
+ // ── uncaught exceptions ────────────────────────────────────────────────────────
492
+
493
+ const STACK_FRAME_RE = /^\s*at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?$/;
494
+
495
+ /**
496
+ * Parse a V8 stack string into frames, innermost LAST (mirroring Python's
497
+ * traceback.extract_tb order). `code` is unavailable from a stack string.
498
+ */
499
+ function parseStackFrames(stack: string): ExcFrame[] {
500
+ const frames: ExcFrame[] = [];
501
+ for (const lineStr of stack.split('\n')) {
502
+ const m = STACK_FRAME_RE.exec(lineStr);
503
+ if (!m) continue;
504
+ frames.push({ func: m[1] ?? '<anonymous>', file: m[2], line: parseInt(m[3], 10), code: null });
505
+ }
506
+ frames.reverse();
507
+ return frames;
508
+ }
509
+
510
+ /** Clean, machine-readable form of an error for the JSON audit / UI tables. */
511
+ function buildExcStructured(err: Error): ExcStructured {
512
+ const frames = parseStackFrames(err.stack ?? '');
513
+ const last = frames.length ? frames[frames.length - 1] : null;
514
+ const module = last ? path.basename(last.file).replace(/\.[^.]+$/, '') : null;
515
+ return { type: err.name || 'Error', message: err.message || String(err), module, frames };
516
+ }
517
+
518
+ /**
519
+ * Neat, aligned, stdlib-only trace for --debug console output. Internal
520
+ * runspec frames are dropped from the *display* (full trace still hits the
521
+ * file). Falls back to the full list if filtering empties it.
522
+ */
523
+ function formatCompactTrace(err: Error, frames: ExcFrame[]): string {
524
+ const shown = frames.filter((f) => !f.file.includes(RUNSPEC_PKG_DIR));
525
+ const use = shown.length ? shown : frames;
526
+ const locs = use.map((f) => `${path.basename(f.file)}:${f.line}`);
527
+ const width = Math.max(0, ...locs.map((l) => l.length));
528
+ const lines = [`${err.name || 'Error'}: ${err.message}`, ''];
529
+ use.forEach((f, i) => {
530
+ lines.push(` ${locs[i].padEnd(width)} ${f.func}`.trimEnd());
531
+ });
532
+ return lines.join('\n');
533
+ }
534
+
535
+ /** Emit the structured exception record straight to the handlers (file keeps it). */
536
+ function emitExceptionRecord(err: Error, structured: ExcStructured): void {
537
+ if (_handlers.length === 0) return;
538
+ const record: LogRecord = {
539
+ ts: new Date(),
540
+ levelNum: 50,
541
+ loggerName: EXCEPTION_LOGGER,
542
+ message: 'uncaught exception',
543
+ error: err,
544
+ excStructured: structured as unknown as Record<string, unknown>,
545
+ };
546
+ for (const h of _handlers) {
547
+ try {
548
+ if (record.levelNum >= h.level) h.emit(record);
549
+ } catch {
550
+ // never disrupt
551
+ }
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Uniform handling for an uncaught error: always write a structured record to
557
+ * the audit file, feed the run summary (when on), and show a neat compact
558
+ * trace on the console only with --debug (otherwise a single concise line).
559
+ * Exported for tests so they can exercise it without triggering process.exit.
560
+ */
561
+ export function _handleUncaught(err: Error): void {
562
+ const structured = buildExcStructured(err);
563
+
564
+ // Always to the audit file (independent of --debug and of the summary toggle).
565
+ emitExceptionRecord(err, structured);
566
+
567
+ if (_summaryState) {
568
+ _summaryState.exception = { type: structured.type, message: structured.message, traceback: err.stack ?? '' };
569
+ _summaryState.exitCode = 1;
570
+ }
571
+
572
+ // Console: full compact trace only with --debug, else a one-liner.
573
+ try {
574
+ if (_debug) {
575
+ process.stderr.write(formatCompactTrace(err, structured.frames) + '\n');
576
+ } else {
577
+ process.stderr.write(`ERROR: ${structured.type}: ${structured.message} (run with --debug for traceback)\n`);
578
+ }
579
+ } catch {
580
+ process.stderr.write((err.stack ?? String(err)) + '\n'); // safety fallback
581
+ }
582
+ }
583
+
465
584
  function installExitHooks(): void {
466
585
  if (_exitHooksInstalled) return;
467
586
  _exitHooksInstalled = true;
@@ -469,43 +588,25 @@ function installExitHooks(): void {
469
588
  process.on('exit', (code) => {
470
589
  if (_summaryState && !_summaryState.emitted) {
471
590
  // process.exitCode wins over the explicit exception capture only if
472
- // it's non-zero — uncaughtException already set state.exitCode=1.
591
+ // it's non-zero — _handleUncaught already set state.exitCode=1.
473
592
  if (code !== 0 && _summaryState.exitCode === 0) _summaryState.exitCode = code;
474
593
  emitRunSummary();
475
594
  }
476
595
  });
477
596
 
478
597
  // Skip the crash-handlers under jest — they call process.exit(1), which
479
- // would tear down the test runner if any test ever produced an unhandled
480
- // rejection. The 'exit' hook above is harmless and still runs.
598
+ // would tear down the test runner. _handleUncaught is unit-tested directly.
599
+ // The 'exit' hook above is harmless and still runs.
481
600
  if (process.env['JEST_WORKER_ID'] !== undefined) return;
482
601
 
483
602
  process.on('uncaughtException', (err: Error) => {
484
- if (_summaryState) {
485
- _summaryState.exception = {
486
- type: err.name || 'Error',
487
- message: err.message || String(err),
488
- traceback: err.stack ?? '',
489
- };
490
- _summaryState.exitCode = 1;
491
- }
492
- // Preserve default Node behaviour: print and exit non-zero. The 'exit'
493
- // hook above will fire and run emitRunSummary().
494
- process.stderr.write((err.stack ?? String(err)) + '\n');
603
+ _handleUncaught(err);
495
604
  process.exit(1);
496
605
  });
497
606
 
498
607
  process.on('unhandledRejection', (reason: unknown) => {
499
- if (_summaryState) {
500
- const err = reason instanceof Error ? reason : new Error(String(reason));
501
- _summaryState.exception = {
502
- type: err.name || 'Error',
503
- message: err.message || String(reason),
504
- traceback: err.stack ?? '',
505
- };
506
- _summaryState.exitCode = 1;
507
- }
508
- process.stderr.write(`Unhandled rejection: ${reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)}\n`);
608
+ const err = reason instanceof Error ? reason : new Error(String(reason));
609
+ _handleUncaught(err);
509
610
  process.exit(1);
510
611
  });
511
612
  }
@@ -554,6 +655,7 @@ export function configureLogging(opts: ConfigureLoggingOptions): void {
554
655
  if (!opts.logCfg || _configured) return;
555
656
 
556
657
  const debug = opts.debug ?? false;
658
+ _debug = debug;
557
659
  const floor = debug ? LEVEL_NUM['debug'] : LEVEL_NUM['info'];
558
660
 
559
661
  _handlers.push(new StdoutHandler(floor, debug));
@@ -589,9 +691,13 @@ export function configureLogging(opts: ConfigureLoggingOptions): void {
589
691
  user,
590
692
  userTarget,
591
693
  };
592
- installExitHooks();
593
694
  }
594
695
 
696
+ // Uncaught-exception handling is always installed (independent of the summary
697
+ // toggle) so the structured exception record reaches the audit file even when
698
+ // summary is off. The exit hook only flushes a summary when _summaryState is set.
699
+ installExitHooks();
700
+
595
701
  _configured = true;
596
702
  }
597
703
 
@@ -599,6 +705,7 @@ export function configureLogging(opts: ConfigureLoggingOptions): void {
599
705
 
600
706
  export function _resetForTest(): void {
601
707
  _configured = false;
708
+ _debug = false;
602
709
  _loggers.clear();
603
710
  _handlers.length = 0;
604
711
  _summaryState = null;
@@ -606,4 +713,4 @@ export function _resetForTest(): void {
606
713
  // they no-op when _summaryState is null, which is the test-time state.
607
714
  }
608
715
 
609
- export { _periodForDate, RUN_SUMMARY_LOGGER };
716
+ export { _periodForDate, RUN_SUMMARY_LOGGER, EXCEPTION_LOGGER, buildExcStructured, formatCompactTrace };
package/src/models.ts CHANGED
@@ -19,6 +19,9 @@ export interface ArgSpec {
19
19
  description?: string;
20
20
  options?: string[];
21
21
  range?: [number, number];
22
+ pattern?: string; // regex the value must fully match (str only)
23
+ minLength?: number; // minimum string length (str only)
24
+ maxLength?: number; // maximum string length (str only)
22
25
  multiple?: boolean;
23
26
  delimiter?: string;
24
27
  short?: string;
@@ -48,6 +51,7 @@ export interface ScriptSpec {
48
51
  autonomyReason?: string;
49
52
  output?: string;
50
53
  serve?: boolean | string[];
54
+ requireCommand?: boolean;
51
55
  args: Record<string, ArgSpec>;
52
56
  groups: Record<string, GroupSpec>;
53
57
  commands: Record<string, ScriptSpec>;
package/src/parser.ts CHANGED
@@ -4,7 +4,7 @@ import { loadRaw } from './loader';
4
4
  import { inferScript, effectiveAutonomy } from './inference';
5
5
  import { coerce } from './types';
6
6
  import { validateArgs, validateGroups, raiseIfErrors } from './validator';
7
- import { RunSpecError } from './errors';
7
+ import { RunSpecError, formatMissingCommand } from './errors';
8
8
  import { configureLogging } from './logging_setup';
9
9
  import type { ParsedArgs, ScriptSpec, ArgSpec } from './models';
10
10
 
@@ -13,10 +13,12 @@ export interface ParseOptions {
13
13
  argv?: string[];
14
14
  cwd?: string;
15
15
  configPath?: string;
16
+ /** Internal: enforce `require-command` (real CLI parsing). loadSpec sets false. */
17
+ _enforceRequiredCommand?: boolean;
16
18
  }
17
19
 
18
20
  export function parse(opts: ParseOptions = {}): ParsedArgs {
19
- const { scriptName, argv: argvOverride, cwd, configPath: configPathOverride } = opts;
21
+ const { scriptName, argv: argvOverride, cwd, configPath: configPathOverride, _enforceRequiredCommand = true } = opts;
20
22
 
21
23
  const { configPath } = configPathOverride ? { configPath: configPathOverride } : findConfig(cwd);
22
24
  const raw = loadRaw(configPath);
@@ -90,6 +92,21 @@ export function parse(opts: ParseOptions = {}): ParsedArgs {
90
92
  process.exit(0);
91
93
  }
92
94
 
95
+ // Enforce require-command on the resolved leaf: if the deepest matched node
96
+ // still requires a command and has commands, none was chosen at that level.
97
+ // Checking the leaf (not commandPath.length) enforces at every nesting depth.
98
+ // Skipped by loadSpec — introspection/emit must not be blocked. Runs after
99
+ // --help so `<name> --help` still lists the commands.
100
+ if (
101
+ _enforceRequiredCommand &&
102
+ activeScript.requireCommand &&
103
+ Object.keys(activeScript.commands ?? {}).length > 0
104
+ ) {
105
+ throw new RunSpecError(
106
+ formatMissingCommand([name, ...commandPath].join(' '), Object.keys(activeScript.commands)),
107
+ );
108
+ }
109
+
93
110
  let parsedValues = parseArgv(argv, activeScript.args ?? {});
94
111
  parsedValues = applyEnv(parsedValues, activeScript.args ?? {}, name);
95
112
  parsedValues = applyDefaults(parsedValues, activeScript.args ?? {});
@@ -144,7 +161,7 @@ export function parse(opts: ParseOptions = {}): ParsedArgs {
144
161
  }
145
162
 
146
163
  export function loadSpec(opts: ParseOptions = {}): ParsedArgs {
147
- return parse({ ...opts, argv: [] });
164
+ return parse({ ...opts, argv: [], _enforceRequiredCommand: false });
148
165
  }
149
166
 
150
167
  function inferFromArgv(): string {
@@ -336,7 +353,7 @@ export function printHelp(name: string, script: ScriptSpec, commandPath: string[
336
353
 
337
354
  // Commands section
338
355
  if (Object.keys(commands).length > 0) {
339
- console.log('\nCommands:');
356
+ console.log(script.requireCommand ? '\nCommands (required):' : '\nCommands:');
340
357
  const cmdCol = Math.max(...Object.keys(commands).map((c) => c.length)) + 2;
341
358
  for (const [cmdName, cmdSpec] of Object.entries(commands)) {
342
359
  const desc = cmdSpec.description ?? '';
package/src/types.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import * as path from 'path';
2
2
  import type { ArgSpec } from './models';
3
- import { formatInvalidChoice } from './errors';
3
+ import {
4
+ formatInvalidChoice,
5
+ formatInvalidPattern,
6
+ formatTooShort,
7
+ formatTooLong,
8
+ formatInvalidItems,
9
+ } from './errors';
4
10
 
5
11
  export type TypeCoercer = (value: unknown, spec: ArgSpec) => unknown;
6
12
 
@@ -18,6 +24,25 @@ export function coerce(value: unknown, spec: ArgSpec): unknown {
18
24
  `Unknown type '${typeName}' for argument '${spec.name}'. Registered types: ${[...registry.keys()].sort().join(', ')}\nRegister custom types with registerType().`,
19
25
  );
20
26
  }
27
+ // Multiple-valued args coerce and validate each item independently and return
28
+ // a list. The arg's type (and its pattern / length / range / choice checks)
29
+ // applies per item. `rest` manages its own list, so it is excluded.
30
+ if (spec.multiple && typeName !== 'rest') {
31
+ const items = Array.isArray(value) ? value : [value];
32
+ const out: unknown[] = [];
33
+ const failures: Array<[number, unknown, string]> = [];
34
+ items.forEach((item, i) => {
35
+ try {
36
+ out.push(coercer(item, spec));
37
+ } catch (e) {
38
+ failures.push([i + 1, item, (e as Error).message]);
39
+ }
40
+ });
41
+ if (failures.length > 0) {
42
+ throw new Error(formatInvalidItems(spec.name ?? '?', items.length, failures));
43
+ }
44
+ return out;
45
+ }
21
46
  try {
22
47
  return coercer(value, spec);
23
48
  } catch (e) {
@@ -31,8 +56,11 @@ export function listTypes(): string[] {
31
56
  return [...registry.keys()].sort();
32
57
  }
33
58
 
34
- function coerceStr(value: unknown): string {
35
- return String(value);
59
+ function coerceStr(value: unknown, spec: ArgSpec): string {
60
+ const coerced = String(value);
61
+ checkLength(coerced, spec);
62
+ checkPattern(coerced, spec);
63
+ return coerced;
36
64
  }
37
65
 
38
66
  function coerceInt(value: unknown, spec: ArgSpec): number {
@@ -87,6 +115,26 @@ function checkRange(value: number, spec: ArgSpec): void {
87
115
  }
88
116
  }
89
117
 
118
+ // String-only validation (str coercer). Mirrors Python's _check_length/_check_pattern.
119
+ function checkLength(value: string, spec: ArgSpec): void {
120
+ if (spec.minLength !== undefined && value.length < spec.minLength) {
121
+ throw new Error(formatTooShort(value, spec.minLength, spec.name ?? '?'));
122
+ }
123
+ if (spec.maxLength !== undefined && value.length > spec.maxLength) {
124
+ throw new Error(formatTooLong(value, spec.maxLength, spec.name ?? '?'));
125
+ }
126
+ }
127
+
128
+ function checkPattern(value: string, spec: ArgSpec): void {
129
+ if (spec.pattern === undefined) return;
130
+ // (?:…) anchoring reproduces Python's re.fullmatch even when the pattern
131
+ // contains a top-level alternation (plain ^p$ would mis-bind `a|b`).
132
+ const regex = new RegExp(`^(?:${spec.pattern})$`);
133
+ if (!regex.test(value)) {
134
+ throw new Error(formatInvalidPattern(value, spec.pattern, spec.name ?? '?'));
135
+ }
136
+ }
137
+
90
138
  registerType('str', coerceStr);
91
139
  registerType('int', coerceInt);
92
140
  registerType('float', coerceFloat);
@@ -0,0 +1,48 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { loadRaw } from '../src/loader';
5
+ import { inferScript } from '../src/inference';
6
+ import { buildSchema } from '../src/cli';
7
+
8
+ function schemaFor(toml: string, name: string): Record<string, unknown> {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-emit-test-'));
10
+ const file = path.join(dir, 'runspec.toml');
11
+ fs.writeFileSync(file, toml);
12
+ const raw = loadRaw(file);
13
+ const script = inferScript(raw.runnables[name], raw.config.autonomyDefault);
14
+ return buildSchema(name, script, 'mcp');
15
+ }
16
+
17
+ test('string validation fields emit to JSON Schema', () => {
18
+ const schema = schemaFor(
19
+ `
20
+ [greet]
21
+ [greet.args]
22
+ slug = {type = "str", pattern = "[a-z]+-[0-9]+", min-length = 3, max-length = 10}
23
+ `,
24
+ 'greet',
25
+ );
26
+ const props = (schema['inputSchema'] as Record<string, Record<string, unknown>>)['properties'];
27
+ const slug = (props as Record<string, Record<string, unknown>>)['slug'];
28
+ expect(slug['type']).toBe('string');
29
+ expect(slug['pattern']).toBe('[a-z]+-[0-9]+');
30
+ expect(slug['minLength']).toBe(3);
31
+ expect(slug['maxLength']).toBe(10);
32
+ });
33
+
34
+ test('multiple arg emits array with per-item constraints inside items', () => {
35
+ const schema = schemaFor(
36
+ `
37
+ [deploy]
38
+ [deploy.args]
39
+ ticket = {type = "str", multiple = true, pattern = "[A-Z]+-[0-9]+"}
40
+ `,
41
+ 'deploy',
42
+ );
43
+ const props = (schema['inputSchema'] as Record<string, Record<string, unknown>>)['properties'];
44
+ const ticket = (props as Record<string, Record<string, unknown>>)['ticket'];
45
+ expect(ticket['type']).toBe('array');
46
+ // The per-item constraint validates each element, so it belongs in items.
47
+ expect(ticket['items']).toEqual({ type: 'string', pattern: '[A-Z]+-[0-9]+' });
48
+ });
@@ -1,4 +1,4 @@
1
- import { inferArg, inferScript, effectiveAutonomy, isMoreRestrictive } from '../src/inference';
1
+ import { inferArg, inferScript, effectiveAutonomy, isMoreRestrictive, tsTypeOf } from '../src/inference';
2
2
  import { RunSpecError } from '../src/errors';
3
3
  import type { ArgSpec, ScriptSpec } from '../src/models';
4
4
 
@@ -151,3 +151,31 @@ test('manual is more restrictive than confirm', () => {
151
151
  test('autonomous is not more restrictive than confirm', () => {
152
152
  expect(isMoreRestrictive('autonomous', 'confirm')).toBe(false);
153
153
  });
154
+
155
+ // ── tsTypeOf (derived value type, computed on demand) ──────────────────────────
156
+
157
+ test('tsTypeOf: scalar types', () => {
158
+ expect(tsTypeOf(makeArg({ type: 'str' }))).toBe('string');
159
+ expect(tsTypeOf(makeArg({ type: 'int' }))).toBe('number');
160
+ expect(tsTypeOf(makeArg({ type: 'flag' }))).toBe('boolean');
161
+ expect(tsTypeOf(makeArg({ type: 'path' }))).toBe('string');
162
+ });
163
+
164
+ test('tsTypeOf: multiple suffixes []', () => {
165
+ expect(tsTypeOf(makeArg({ type: 'str', multiple: true }))).toBe('string[]');
166
+ expect(tsTypeOf(makeArg({ type: 'int', multiple: true }))).toBe('number[]');
167
+ });
168
+
169
+ test('tsTypeOf: rest is string[]', () => {
170
+ expect(tsTypeOf(makeArg({ type: 'rest' }))).toBe('string[]');
171
+ });
172
+
173
+ test('tsTypeOf: custom type falls back to unknown', () => {
174
+ expect(tsTypeOf(makeArg({ type: 'json-file', multiple: true }))).toBe('unknown[]');
175
+ });
176
+
177
+ test('tsTypeOf works on an inferred ArgSpec; type stays the item type', () => {
178
+ const arg = inferArg(makeArg({ type: 'str', multiple: true }));
179
+ expect(arg.type).toBe('str');
180
+ expect(tsTypeOf(arg)).toBe('string[]');
181
+ });
@@ -171,6 +171,26 @@ describe('complex.toml', () => {
171
171
  const run = inferred.commands['run'];
172
172
  expect(run.autonomyReason).toBe('Writes output files and may call external APIs');
173
173
  });
174
+
175
+ test('run subcommand: label has pattern / min-length / max-length', () => {
176
+ const raw = loadRaw(COMPLEX);
177
+ const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
178
+ const label = inferred.commands['run'].args['label'];
179
+ expect(label.type).toBe('str');
180
+ expect(label.pattern).toBe('[a-z][a-z0-9-]+');
181
+ expect(label.minLength).toBe(3);
182
+ expect(label.maxLength).toBe(32);
183
+ });
184
+
185
+ test('db runnable: require-command with subcommands', () => {
186
+ const raw = loadRaw(COMPLEX);
187
+ const db = raw.runnables['db'];
188
+ expect(db.requireCommand).toBe(true);
189
+ expect(db.commands['migrate']).toBeDefined();
190
+ expect(db.commands['seed']).toBeDefined();
191
+ // Defaults to false where unset.
192
+ expect(raw.runnables['pipeline'].requireCommand).toBe(false);
193
+ });
174
194
  });
175
195
 
176
196
  // ── cross-fixture: inference rules ────────────────────────────────────────────