Commit466ae15cRecorded31 Jul 2026Repositorysigil-args

Fix silent exit-0 paths in run-command and bare-dash parsing

Message

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.

Changed
 package.sgl        |  2 +-
 src/sigil/args.sgl | 59 +++++++++++++++++++++++++++++++++++++++++++++++------------
 test/test-args.sgl | 47 +++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 95 insertions(+), 13 deletions(-)
Diff
package.sglmodified
@@ -4,7 +4,7 @@
4
5
(package
6
name: "sigil-args"
7
version: "0.18.0"
+7
version: "0.18.1"
8
sigil: "^0.17"
9
description: "Command-line argument parsing for Sigil"
10
url: "https://codeberg.org/sigil/sigil-args"
src/sigil/args.sglmodified
@@ -392,7 +392,16 @@
392
;;; Parse short option(s): -v, -vvv, -o value
393
(define (parse-short-options options subcommands arg rest opts args errors subcmd)
394
(let ((chars (substring arg 1 (string-length arg))))
395
(parse-short-chars options subcommands chars rest opts args errors subcmd)))
+395
(if (= (string-length chars) 0)
+396
;; A bare "-" is not an option: keep it as a positional argument
+397
;; (the unix stdin-placeholder convention). It used to be
+398
;; silently DROPPED here — `tool -` parsed identically to
+399
;; `tool`, so a caller passing "-" got the help text and exit 0
+400
;; instead of an honest result (sigil exit-contract audit,
+401
;; 2026-07-31).
+402
(parse-args-loop options subcommands rest opts
+403
(append args (list arg)) errors #f subcmd)
+404
(parse-short-chars options subcommands chars rest opts args errors subcmd))))
405
406
;;; Parse individual characters in short option string
407
(define (parse-short-chars options subcommands chars rest opts args errors subcmd)
@@ -664,12 +673,14 @@
673
(print-help target-cmd)
674
(exit 0))
675
667
;; Check for errors
+676
;; Check for errors. Diagnostics go to stderr so a caller
+677
;; capturing stdout (help still goes there) sees them; a parse
+678
;; error buried in captured stdout is invisible in pipelines.
679
((not (null? errors))
680
(for-each (lambda (err)
670
(display "Error: ")
671
(display err)
672
(newline))
+681
(display "Error: " (current-error-port))
+682
(display err (current-error-port))
+683
(newline (current-error-port)))
684
errors)
685
(newline)
686
(print-help target-cmd)
@@ -682,9 +693,23 @@
693
(if (command-passthrough subcmd)
694
(handler opts args passthrough)
695
(handler opts args))
685
(begin
686
(print-help subcmd)
687
(exit 0)))))
+696
;; No handler: bare invocation gets help and exit 0, but
+697
;; LEFTOVER ARGS mean the caller asked for a subcommand
+698
;; that does not exist — that must not report success.
+699
;; Before this split, `sigil channel not-a-real-sub`
+700
;; printed help and exited 0 on every backend (sigil
+701
;; exit-contract audit, 2026-07-31).
+702
(if (null? args)
+703
(begin
+704
(print-help subcmd)
+705
(exit 0))
+706
(begin
+707
(display "Error: Unknown command: " (current-error-port))
+708
(display (car args) (current-error-port))
+709
(newline (current-error-port))
+710
(newline)
+711
(print-help subcmd)
+712
(exit 1))))))
713
714
;; No subcommand - run main handler or show help
715
(else
@@ -693,11 +718,21 @@
718
(if (command-passthrough cmd)
719
(handler opts args passthrough)
720
(handler opts args))
696
;; No handler and has subcommands - show help
+721
;; No handler and has subcommands - show help; same
+722
;; leftover-args rule as above: an unmatched word is an
+723
;; error, not a help request.
724
(if (not (null? (command-subcommands cmd)))
698
(begin
699
(print-help cmd)
700
(exit 0))
+725
(if (null? args)
+726
(begin
+727
(print-help cmd)
+728
(exit 0))
+729
(begin
+730
(display "Error: Unknown command: " (current-error-port))
+731
(display (car args) (current-error-port))
+732
(newline (current-error-port))
+733
(newline)
+734
(print-help cmd)
+735
(exit 1)))
736
;; No handler, no subcommands - nothing to do
737
#f)))))))
738
test/test-args.sglmodified
@@ -303,4 +303,51 @@
303
(assert-equal '("build") (parse-result-args result))
304
(assert-equal '("-v") (parse-result-passthrough result))))
305
+306
(test-group "bare dash is a positional argument"
+307
;; A lone "-" used to be silently dropped by the short-option parser, so
+308
;; `tool -` parsed identically to `tool` — help and exit 0 instead of an
+309
;; honest result (sigil exit-contract audit, 2026-07-31).
+310
(test "lone dash survives as a positional"
+311
(let* ((cmd (command name: "test"
+312
options: (list (option name: 'verbose short: #\v))))
+313
(result (parse-args cmd '("-"))))
+314
(assert-null (parse-result-errors result))
+315
(assert-equal '("-") (parse-result-args result))))
+316
+317
(test "dash mixes with other positionals in order"
+318
(let* ((cmd (command name: "test"))
+319
(result (parse-args cmd '("a" "-" "b"))))
+320
(assert-null (parse-result-errors result))
+321
(assert-equal '("a" "-" "b") (parse-result-args result))))
+322
+323
(test "dash does not swallow a following flag"
+324
(let* ((cmd (command name: "test"
+325
options: (list (option name: 'verbose short: #\v))))
+326
(result (parse-args cmd '("-" "-v"))))
+327
(assert-null (parse-result-errors result))
+328
(assert-equal '("-") (parse-result-args result))
+329
(assert-equal #t (alist-get 'verbose (parse-result-opts result)))))
+330
+331
;; Control: the change must not have loosened real short options.
+332
(test "unknown short option still errors"
+333
(let* ((cmd (command name: "test"
+334
options: (list (option name: 'verbose short: #\v))))
+335
(result (parse-args cmd '("-Z"))))
+336
(assert-true (pair? (parse-result-errors result))))))
+337
+338
(test-group "unmatched word under a handler-less subcommand"
+339
;; run-command now turns this shape into an error + exit 1 (it used to
+340
;; print help and exit 0: `sigil channel not-a-real-sub` reported
+341
;; success on every backend). run-command itself calls exit, so what is
+342
;; unit-testable in-process is the parse shape it dispatches on: the
+343
;; matched handler-less subcommand with a leftover positional word.
+344
(test "leftover word lands in args of the matched subcommand"
+345
(let* ((pub (command name: "publish"))
+346
(chan (command name: "channel" subcommands: (list pub)))
+347
(root (command name: "sigil" subcommands: (list chan)))
+348
(result (parse-args root '("channel" "not-a-real-sub"))))
+349
(assert-null (parse-result-errors result))
+350
(assert-equal "channel" (command-name (parse-result-subcommand result)))
+351
(assert-equal '("not-a-real-sub") (parse-result-args result)))))
+352
353
(run-tests)