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.
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +44 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/inference.d.ts +6 -0
- package/dist/inference.d.ts.map +1 -1
- package/dist/inference.js +25 -0
- package/dist/inference.js.map +1 -1
- package/dist/loader.js +4 -0
- package/dist/loader.js.map +1 -1
- package/dist/logging_setup.d.ts +29 -1
- package/dist/logging_setup.d.ts.map +1 -1
- package/dist/logging_setup.js +120 -30
- package/dist/logging_setup.js.map +1 -1
- package/dist/models.d.ts +4 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/parser.d.ts +2 -0
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +13 -3
- package/dist/parser.js.map +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +44 -2
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +3 -0
- package/src/errors.ts +48 -0
- package/src/index.ts +1 -0
- package/src/inference.ts +25 -0
- package/src/loader.ts +4 -0
- package/src/logging_setup.ts +137 -30
- package/src/models.ts +4 -0
- package/src/parser.ts +21 -4
- package/src/types.ts +51 -3
- package/tests/test_emit_schema.test.ts +48 -0
- package/tests/test_inference.test.ts +29 -1
- package/tests/test_integration.test.ts +20 -0
- package/tests/test_loader.test.ts +33 -0
- package/tests/test_parser.test.ts +83 -1
- package/tests/test_run_summary.test.ts +99 -0
- 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,
|
package/src/logging_setup.ts
CHANGED
|
@@ -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
|
|
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 —
|
|
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
|
|
480
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
|
|
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 {
|
|
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
|
-
|
|
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 ────────────────────────────────────────────
|