Fix silent exit-0 paths in run-command and bare-dash parsing
Three exit-contract defects found by the sigil CLI exit-contract audit (2026-07-31), all of the form "a refused or unmatched invocation reports success":
- run-command: a matched handler-less subcommand with leftover args printed help and exited 0, so sigil channel not-a-real-sub reported success on every backend. Leftover args now produce "Error: Unknown command: <word>" on stderr, the help text, and exit 1. A bare invocation (no leftover args) keeps the help + exit 0 behavior. The same split applies to the top-level no-handler branch.
- parse-short-options: a lone "-" was silently dropped, so tool - parsed identically to tool (help, exit 0). It is now kept as a positional argument, per the unix stdin-placeholder convention.
- run-command parse errors printed to stdout; they now go to stderr so callers capturing stdout still see diagnostics. Help stays on stdout.
Adds parse-level tests for the bare-dash and leftover-word shapes, including an unknown-short-option control. run-command's exit behavior itself is subprocess-level and is covered end-to-end by the sigil monorepo's test/integration/test-cli-exit-contract.sh once the monorepo adopts this release.
Bump version to 0.18.1.
package.sgl | 2 +-
src/sigil/args.sgl | 59 +++++++++++++++++++++++++++++++++++++++++++++++------------
test/test-args.sgl | 47 +++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 95 insertions(+), 13 deletions(-)package.sglmodified
(package name: "sigil-args" version: "0.18.0" version: "0.18.1" sigil: "^0.17" description: "Command-line argument parsing for Sigil" url: "https://codeberg.org/sigil/sigil-args"src/sigil/args.sglmodified
;;; Parse short option(s): -v, -vvv, -o value (define (parse-short-options options subcommands arg rest opts args errors subcmd) (let ((chars (substring arg 1 (string-length arg)))) (parse-short-chars options subcommands chars rest opts args errors subcmd))) (if (= (string-length chars) 0) ;; A bare "-" is not an option: keep it as a positional argument ;; (the unix stdin-placeholder convention). It used to be ;; silently DROPPED here — `tool -` parsed identically to ;; `tool`, so a caller passing "-" got the help text and exit 0 ;; instead of an honest result (sigil exit-contract audit, ;; 2026-07-31). (parse-args-loop options subcommands rest opts (append args (list arg)) errors #f subcmd) (parse-short-chars options subcommands chars rest opts args errors subcmd)))) ;;; Parse individual characters in short option string (define (parse-short-chars options subcommands chars rest opts args errors subcmd) (print-help target-cmd) (exit 0)) ;; Check for errors ;; Check for errors. Diagnostics go to stderr so a caller ;; capturing stdout (help still goes there) sees them; a parse ;; error buried in captured stdout is invisible in pipelines. ((not (null? errors)) (for-each (lambda (err) (display "Error: ") (display err) (newline)) (display "Error: " (current-error-port)) (display err (current-error-port)) (newline (current-error-port))) errors) (newline) (print-help target-cmd) (if (command-passthrough subcmd) (handler opts args passthrough) (handler opts args)) (begin (print-help subcmd) (exit 0))))) ;; No handler: bare invocation gets help and exit 0, but ;; LEFTOVER ARGS mean the caller asked for a subcommand ;; that does not exist — that must not report success. ;; Before this split, `sigil channel not-a-real-sub` ;; printed help and exited 0 on every backend (sigil ;; exit-contract audit, 2026-07-31). (if (null? args) (begin (print-help subcmd) (exit 0)) (begin (display "Error: Unknown command: " (current-error-port)) (display (car args) (current-error-port)) (newline (current-error-port)) (newline) (print-help subcmd) (exit 1)))))) ;; No subcommand - run main handler or show help (else (if (command-passthrough cmd) (handler opts args passthrough) (handler opts args)) ;; No handler and has subcommands - show help ;; No handler and has subcommands - show help; same ;; leftover-args rule as above: an unmatched word is an ;; error, not a help request. (if (not (null? (command-subcommands cmd))) (begin (print-help cmd) (exit 0)) (if (null? args) (begin (print-help cmd) (exit 0)) (begin (display "Error: Unknown command: " (current-error-port)) (display (car args) (current-error-port)) (newline (current-error-port)) (newline) (print-help cmd) (exit 1))) ;; No handler, no subcommands - nothing to do #f)))))))test/test-args.sglmodified
(assert-equal '("build") (parse-result-args result)) (assert-equal '("-v") (parse-result-passthrough result))))(test-group "bare dash is a positional argument" ;; A lone "-" used to be silently dropped by the short-option parser, so ;; `tool -` parsed identically to `tool` — help and exit 0 instead of an ;; honest result (sigil exit-contract audit, 2026-07-31). (test "lone dash survives as a positional" (let* ((cmd (command name: "test" options: (list (option name: 'verbose short: #\v)))) (result (parse-args cmd '("-")))) (assert-null (parse-result-errors result)) (assert-equal '("-") (parse-result-args result)))) (test "dash mixes with other positionals in order" (let* ((cmd (command name: "test")) (result (parse-args cmd '("a" "-" "b")))) (assert-null (parse-result-errors result)) (assert-equal '("a" "-" "b") (parse-result-args result)))) (test "dash does not swallow a following flag" (let* ((cmd (command name: "test" options: (list (option name: 'verbose short: #\v)))) (result (parse-args cmd '("-" "-v")))) (assert-null (parse-result-errors result)) (assert-equal '("-") (parse-result-args result)) (assert-equal #t (alist-get 'verbose (parse-result-opts result))))) ;; Control: the change must not have loosened real short options. (test "unknown short option still errors" (let* ((cmd (command name: "test" options: (list (option name: 'verbose short: #\v)))) (result (parse-args cmd '("-Z")))) (assert-true (pair? (parse-result-errors result))))))(test-group "unmatched word under a handler-less subcommand" ;; run-command now turns this shape into an error + exit 1 (it used to ;; print help and exit 0: `sigil channel not-a-real-sub` reported ;; success on every backend). run-command itself calls exit, so what is ;; unit-testable in-process is the parse shape it dispatches on: the ;; matched handler-less subcommand with a leftover positional word. (test "leftover word lands in args of the matched subcommand" (let* ((pub (command name: "publish")) (chan (command name: "channel" subcommands: (list pub))) (root (command name: "sigil" subcommands: (list chan))) (result (parse-args root '("channel" "not-a-real-sub")))) (assert-null (parse-result-errors result)) (assert-equal "channel" (command-name (parse-result-subcommand result))) (assert-equal '("not-a-real-sub") (parse-result-args result)))))(run-tests)