# 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: 1. **occurrence arity** - how many times the argument may be supplied 2. **overflow policy** - what happens beyond the limit: silent replace (legacy `-multiple 0` last-wins) or error 3. **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 idiom `punk::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 == 1` forms stay scalar (they are strict single-valued variants of legacy 0); `max > 1` or `-1` yield the occurrence list. This matches the existing boolean truth-test usage (`-multiple` true = 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): `-optional` governs 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 `-multiple` ONCE into internal companion fields while preserving the stored boolean's meaning so every existing truth-test stays correct: - stored `-multiple` boolean = "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. 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` / `-multipleuniqueset` remain 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-paths` fix (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