8.6 KiB
G-053 punk::args range-valued -multiple: occurrence arity with strict duplicate handling
Status: achieved 2026-08-08 Scope: src/modules/punk/args-999999.0a1.0.tm (spec compiler, parse, arg_error/synopsis renderers); src/tests/modules/punk/args/testsuites/args/ Goal: -multiple accepts a {min max} occurrence range (mirroring -choicemultiple; max -1 unbounded) alongside the legacy booleans - so a definition can declare "at most once, repeat is an error" ({0 1}) or bounded repetition ({2 4}) instead of choosing between silent last-wins (0) and unbounded collection (1) - with boolean semantics preserved exactly, including the prepend-defaults/last-wins override idiom. Acceptance: parse raises a usage-style arity error naming the argument for occurrences outside a declared range; boolean -multiple 0/1 behaviour is unchanged (full existing punk::args suite passes untouched); the -optional/range-min reconciliation rule is documented and enforced at define time; the usage table Multi column and synopsis reflect declared ranges; -multipleunique/-multipleuniqueset compose with max>1 ranges unchanged; characterization tests cover the new forms and the value-shape rule.
Context
Boolean -multiple conflates three axes:
- occurrence arity - how many times the argument may be supplied
- overflow policy - what happens beyond the limit: silent replace (legacy
-multiple 0last-wins) or error - value shape - scalar vs list-of-occurrences
The motivating incident (2026-07-10): runtests.tcl's -include-paths was a
non-multiple list option, so repeated -include-paths flags silently last-won -
quietly narrowing a test run while reporting green, and defeating even a recorded
memory note about the gotcha. Repeatable accumulating flags are the dominant
convention in shell-facing CLIs (gcc -I, curl -H, rsync --exclude), so this misuse
recurs. runtests was fixed by making that option -multiple 1, but the general
fix for "repeat should be an error" has no expression today.
A separate -duplicates deny|replace policy flag was considered and rejected: with
-multiple 1 a duplicates policy is meaningless (duplicates ARE the collected
payload), so the flag's validity would depend on another flag's setting, and it
would blur into the existing -multipleunique/-multipleuniqueset territory.
punk::args already has the shape precedent in its own vocabulary: -choicemultiple
is a {min max} pair, not a boolean.
Approach
-multiple accepts a boolean (legacy, semantics preserved exactly) or a
{min max} range (max -1 = unbounded):
-multiple 0- legacy: single-valued, repeats silently replace (last wins), scalar shape. Unchanged - this preserves the prepend-defaults override idiompunk::args::parse [list -flag default {*}$userargs].-multiple 1- legacy: unbounded collection, list shape. Unchanged.-multiple {0 1}- at most once; a second occurrence is a parse (arity) error. This is the "duplicates deny" case.-multiple {2 4},{1 -1}etc - bounded/lower-bounded repetition, collected.
Design decisions to settle (record here when made):
- Value shape for range forms (settled 2026-08-08):
max == 1forms stay scalar (they are strict single-valued variants of legacy 0);max > 1or-1yield the occurrence list. This matches the existing boolean truth-test usage (-multipletrue = list-collect), so{0 1}is scalar and{2 4}/{1 -1}are lists. Pinned by the characterization tests. - -optional vs range-min reconciliation (settled 2026-08-08):
-optionalgoverns presence (the 0..1 of whether the arg appears at all); the range min governs occurrence count when present.-optional 0+ range min>=1 = required (at least min times). Contradictory combinations (range min >= 1 intended as "required" while -optional is also set, or a min that an optional arg can never reach) are rejected at define time with a clear message. - Hot-path canonicalization (settled 2026-08-08): the spec compiler
canonicalizes
-multipleONCE into internal companion fields while preserving the stored boolean's meaning so every existing truth-test stays correct:- stored
-multipleboolean = "list-shape collect" (true for legacy 1,{2 4},{1 -1},{0 -1}; false for legacy 0,{0 1},{1 1}) - all existing collect-vs-replace / scalar-vs-list / leader-value-single-multiple truth-tests keep working unchanged. - new internal
_multiple_min(occurrence floor),_multiple_max(cap, -1 unbounded),_multiple_maxbounded(1 when max is a hard cap that errors on exceed). Legacy 0 -> min 0 max -1 maxbounded 0 (unlimited replace); legacy 1 -> min 0 max -1 maxbounded 0 (unlimited collect);{0 1}-> min 0 max 1 maxbounded 1;{2 4}-> min 2 max 4 maxbounded 1;{1 -1}-> min 1 max -1 maxbounded 0. - only two new runtime check sites: a max-occurrence check at the storage sites (opts + leaders + values; count would exceed max -> arity error) and a min-occurrence check at final validation (count < min -> arity error). The boolean hot path is untouched.
- stored
Display benefits: the usage table's Multi column and the synopsis ?arg...?
rendering gain meaningful bounded-repetition forms (e.g. "0-1", "2-4").
Progress
- 2026-08-08 G-053 implemented (punk::args 0.22.0, project 0.60.0): -multiple now accepts a {min max} range (max -1 = unbounded) alongside the legacy booleans. The spec compiler canonicalises once at resolve: the stored -multiple becomes the computed boolean (list-shape collect: true for max>1 or -1, false for legacy 0 and max==1) so every existing collect-vs-replace / scalar-vs-list / leader-value-single-multiple hot-path truth-test stays correct, and the range companions (min/max/maxbounded) live in a separate per-form MULTIPLE_RANGES dict - NOT in ARG_INFO, so they do not ride along when ARG_INFO is round-tripped as a spec via resolved_def copyfrom (the first attempt stored them in ARG_INFO and broke 29 tests with an 'unrecognised key _multiple_min' resolve error; the separate-dict fix cleared it). Legacy 0/1 and boolean strings (true/false/yes/no) are coerced to the boolean and stay byte-unchanged (no MULTIPLE_RANGES entry for unlimited cases, so {0 -1} is equivalent to legacy 1). Resolve validation: max positive or -1, min <= max, and the -optional/range-min reconciliation (non-zero min forces presence, contradicts -optional -> reject). Parse enforcement: a new PUNKARGS VALIDATION occurrencecount failure class (payload count min | max ) fires in a single post-loop pass per section (opts/leaders/values) via a private::multiple_range_enforce helper; over-max is a hard contradiction (fires in normal and viability-probe modes, parse_status_classify maps it to invalid), under-min is pure end-of-input exhaustion (SUPPRESSED in the G-152 viability probe via the viabilitycheck arg, classified incomplete so a viable form reports incomplete not invalid). The usage-table Multi column reflects the range (0-1 / 2-4 / 1+; greencheck stays for legacy 1), the string renderer emits MULTI:0-1 etc., and the synopsis distinguishes at-most-once (?arg?, no ellipsis) from repeating (arg...). -multipleunique/-multipleuniqueset compose unchanged. define -help documents the range form and the -optional/range-min rule. New testsuite multipleranges.test (28 tests). Legacy untouched by default confirmed: full punk/args suite 399/0 (3 skipped), punk/ns 125/125.
Notes
-multipleunique/-multipleuniquesetremain the uniqueness knobs and only make sense for max > 1; they compose with ranges unchanged.- Related: G-045 (authoring ergonomics, achieved 2026-07-12 - see goals/archive/G-045-punkargs-authoring-ergonomics.md), G-046 (parse-time performance - the canonicalization must not regress the hot path).
- The runtests
-include-pathsfix (repeatable, accumulate, single-list form still accepted) shipped independently on 2026-07-10 and does not depend on this goal. - Archived-goal references in this file: G-046 achieved 2026-07-10 (goals/archive/G-046-punkargs-deferred-help-and-fixes.md).
- Referenced by G-083 (argument relations - "folding into G-053" considered and rejected there; achieved - see goals/archive/G-083-punkargs-argument-relations.md) and G-084 (-parsekey cross-member -multiple collection rides this goal's occurrence arity) - recorded 2026-07-24 after overlap review.
Follow-ons
Follow-on: G-084 cross-member -multiple collection on a shared parsekey rides this goal's occurrence-arity model - decide accumulate-in-received-order vs error-on-cross-member-combination now that the {min max} vocabulary is landed (see goals/G-084-punkargs-parsekey-completeness.md) => goal G-084