Browse Source

G-053 achieved: punk::args range-valued -multiple occurrence arity (punk::args 0.22.0, project 0.60.0)

-multiple now accepts a {min max} range (max -1 = unbounded) alongside the
legacy booleans 0/1, so a definition can declare at most once ({0 1}, a second
occurrence is a parse error), bounded repetition ({2 4}), or one-or-more
({1 -1}) instead of choosing between silent last-wins (0) and unbounded
collection (1).

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. 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
with a clear message; declare -optional 0). The value-shape rule: max==1 forms
stay scalar, max>1 or -1 yield the occurrence list.

Parse enforcement: a new PUNKARGS VALIDATION occurrencecount failure class
(payload <argname> count <n> min <m> | max <m>) 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 both 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). Legacy -multiple 1 required with 0 occurrences still reports
trailingvaluecount (byte-unchanged, not occurrencecount).

Rendering: the usage-table Multi column reflects the range (0-1 / 2-4 / 1+ for
unbounded-with-floor; the 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
with max>1 ranges unchanged. define -help documents the range form and the
-optional/range-min rule.

New testsuite multipleranges.test (28 tests: 13 define-time
canonicalisation+validation, 12 parse-time enforcement incl parse_status
verdicts and a legacy-required-still-trailingvaluecount guard, 3 rendering).
Legacy untouched by default confirmed: full punk/args suite 399/0 (3 skipped),
punk/ns 125/125. All G-053 acceptance criteria met; goal flipped to achieved
2026-08-08 and archived (detail -> goals/archive/, index -> GOALS-archive.md,
reference sweep applied to G-072/G-084, deferred G-084 cross-member -multiple
collection decision recorded as a Follow-on => goal G-084).

Project version 0.59.0 -> 0.60.0 (minor: backward-compatible behaviour
addition). punk::args module 0.21.0 -> 0.22.0.

Assisted-by: harness=pi; primary-model=huggingface/zai-org/GLM-5.2; api-location=huggingface.co
master
Julian Noble 2 days ago
parent
commit
2aad4d1777
  1. 20
      CHANGELOG.md
  2. 4
      GOALS-archive.md
  3. 3
      GOALS.md
  4. 75
      goals/G-053-punkargs-multiple-ranges.md
  5. 3
      goals/G-072-punkargs-compound-clause-types.md
  6. 3
      goals/G-084-punkargs-parsekey-completeness.md
  7. 128
      goals/archive/G-053-punkargs-multiple-ranges.md
  8. 2
      punkproject.toml
  9. 163
      src/modules/punk/args-999999.0a1.0.tm
  10. 3
      src/modules/punk/args-buildversion.txt
  11. 2
      src/tests/modules/AGENTS.md
  12. 355
      src/tests/modules/punk/args/testsuites/args/multipleranges.test

20
CHANGELOG.md

@ -5,6 +5,26 @@ The latest `## [X.Y.Z]` header must match the `version` field in `punkproject.to
Entries are newest-first; one bullet per notable change. See the root `AGENTS.md`
"Project Versioning" section for the bump policy.
## [0.60.0] - 2026-08-08
- `punk::args` `-multiple` gains occurrence-arity ranges (G-053): `-multiple`
accepts a `{min max}` range (max `-1` unbounded) alongside the legacy
booleans, so a definition can declare at most once (`{0 1}`; a second
occurrence is a parse error), bounded repetition (`{2 4}`), or one-or-more
(`{1 -1}`) instead of choosing between silent last-wins (0) and unbounded
collection (1). The spec compiler canonicalises once at resolve, keeping the
stored `-multiple` boolean's hot-path meaning so legacy 0/1 stay byte-unchanged;
range companions live in a separate per-form `MULTIPLE_RANGES` dict (not in
ARG_INFO, so they survive `resolved_def` spec round-trips). A new
`PUNKARGS VALIDATION occurrencecount` failure class fires per section
(opts/leaders/values); over-max is a hard contradiction (invalid), under-min
is pure exhaustion (incomplete, suppressed in the G-152 viability probe). The
`-optional`/range-min reconciliation is enforced and documented at define
time. The usage-table Multi column shows `0-1`/`2-4`/`1+` (greencheck stays for
legacy 1), the string renderer emits `MULTI:0-1`, and the synopsis
distinguishes at-most-once (`?arg?`) from repeating (`arg...`).
`-multipleunique`/`-multipleuniqueset` compose unchanged.
## [0.59.0] - 2026-08-08
- `punk::args` gains argument-relation vocabulary and parse-time

4
GOALS-archive.md

@ -20,6 +20,10 @@ Do not edit archived records here except to fix a broken path. Archived detail f
## Archived goals
### G-053 [achieved 2026-08-08] punk::args range-valued -multiple: occurrence arity with strict duplicate handling → detail: goals/archive/G-053-punkargs-multiple-ranges.md
Scope: src/modules/punk/args-999999.0a1.0.tm (spec compiler, parse, arg_error/synopsis renderers); src/tests/modules/punk/args/testsuites/args/
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.
### G-083 [achieved 2026-08-08] punk::args argument relations: strict mutual exclusivity and parsekey-group integrity → detail: goals/archive/G-083-punkargs-argument-relations.md
Scope: src/modules/punk/args-999999.0a1.0.tm (resolve/spec compiler, parse paths incl mash, arg_error/synopsis/usage renderers, errorcode vocabulary); src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm (lsearch, clock clicks dogfood); src/tests/modules/punk/args/testsuites/args/ + testsuites/dev/ (GAP flips + new characterization)
Acceptance: parse raises a usage-style error with a structured errorcode (PUNKARGS VALIDATION optionconflict shape) naming both offending received arguments for -conflicts violations and strict-group co-occurrence, through both the ordinary and mash option paths with prefix abbreviations resolved before the check; define time rejects a -parsekey colliding with a distinct defined argument's name (parsekey_collides_with_defined_optname_GAP flips); legacy behaviour is untouched by default (full existing punk::args suite passes, including the pinned last-wins/prepend-defaults override idiom); the lsearch moduledoc models its documented incompatibilities with the new vocabulary and drops its 'punk::args fixes required for grouped mutually exclusive options' caveat; usage/synopsis output for conflict rules is pinned by characterization tests.

3
GOALS.md

@ -215,9 +215,6 @@ Detail: goals/G-050-synopsis-validity-marking.md
Scope: src/modules/punk/ns-999999.0a1.0.tm (generate_autodef oo branches, cmd_traverse); src/tests/modules/punk/ns/testsuites/ns/cmdhelp.test
Detail: goals/G-052-oo-method-autodef.md
### G-053 [proposed] punk::args range-valued -multiple: occurrence arity with strict duplicate handling
Scope: src/modules/punk/args-999999.0a1.0.tm (spec compiler, parse, arg_error/synopsis renderers); src/tests/modules/punk/args/testsuites/args/
Detail: goals/G-053-punkargs-multiple-ranges.md
### G-055 [proposed] Agent-driven tclcore moduledoc regeneration workflow with behavioural parity verification
Scope: goals/G-055-tclcore-regen-workflow.md (workflow doc); src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm (+ buildversion); src/tests/modules/punk/args/testsuites/args/ (parity pins); TEMP_REFERENCE/tcl9 (read-only source input; source retrieval mechanism deferred to the buildsuites toml configs / G-005 era)

75
goals/G-053-punkargs-multiple-ranges.md

@ -1,75 +0,0 @@
# G-053 punk::args range-valued -multiple: occurrence arity with strict duplicate handling
Status: proposed
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**: lean - `max == 1` forms stay scalar (they are
strict single-valued variants of legacy 0); `max > 1` or `-1` yield the
occurrence list. Whatever is chosen must be pinned by the characterization tests.
- **-optional vs range-min reconciliation**: lean - `-optional` governs presence,
the range governs occurrence count when present; contradictory combinations
(e.g. range min >= 1 with -optional 1 intended as "required") rejected at define
time with a clear message.
- **Hot-path canonicalization**: `-multiple` is truth-tested in many parse/render
sites; a raw `"0 1"` value would fail expr boolean coercion. The spec compiler
should canonicalize once into internal min/max/policy fields (alongside the
existing ARG_INFO/ARG_CHECKS structures) so runtime checks stay cheap.
Display benefits: the usage table's Multi column and the synopsis `?arg...?`
rendering gain meaningful bounded-repetition forms (e.g. "0-1", "2-4").
## 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.

3
goals/G-072-punkargs-compound-clause-types.md

@ -61,7 +61,8 @@ mechanism. Brief examination at drafting time:
allocation screen and allocation.test fixtures are the base this goal's
alternates ride on), G-055 (modelability findings list; its parity workflow
verifies whatever this goal makes expressible), G-053 (occurrence arity -
adjacent clause machinery).
adjacent clause machinery; achieved - see
goals/archive/G-053-punkargs-multiple-ranges.md).
- Display cost matters: the ::try notes warn bracketed alternate forms "get
unwieldy in synopsis listings" - synopsis rendering of compound types is part
of the mechanism's acceptance, not an afterthought.

3
goals/G-084-punkargs-parsekey-completeness.md

@ -50,7 +50,8 @@ _GAP test in src/tests/modules/punk/args/testsuites/args/parsekey.test:
- Related: G-083 (argument relations - shared design context for the
cross-member collection decision; achieved - see
goals/archive/G-083-punkargs-argument-relations.md), G-053 (occurrence arity
of a single argument).
of a single argument; achieved - see
goals/archive/G-053-punkargs-multiple-ranges.md).
- Related: G-151 (achieved 2026-08-05 - goals/archive/G-151-punkargs-annotated-success-render.md) -
its parse_report landing report bridges the parse-result storage-key folds
AT DISPLAY LEVEL only (an argument's -parsekey, else an aliased optionset's

128
goals/archive/G-053-punkargs-multiple-ranges.md

@ -0,0 +1,128 @@
# 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 <argname> count <n> min <m> | max <m>)
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

2
punkproject.toml

@ -1,6 +1,6 @@
[project]
name = "punkshell"
version = "0.59.0"
version = "0.60.0"
license = "BSD-2-Clause"
url = "https://www.gitea1.intx.com.au/jn/punkshell"
#packager: declared identity for published artifacts (declarative, not proof -

163
src/modules/punk/args-999999.0a1.0.tm

@ -1026,11 +1026,20 @@ tcl::namespace::eval punk::args {
-abc is valid and equivalent to -a -b -c
-abcf <value> is valid and equivalent to -a -b -c -f <value>
but -afc <value> is not valid
-multiple <bool> (for leaders & values defines whether
-multiple <bool|{min max}> (for leaders & values defines whether
subsequent received values are stored against the same
argument name - only applies to final leader OR final value)
(for options/flags this allows the opt-val pair or solo
flag to appear multiple times - not necessarily contiguously)
G-053 range form: a 2-element {min max} list (max -1 =
unbounded) declares occurrence arity. max == 1 stays scalar
(at most once; a second occurrence is an error); max > 1 or
-1 collects a list. {0 1} = at most once, {2 4} = two to four
times, {1 -1} = one or more. Boolean 0/1 are unchanged
(unlimited last-wins / unlimited collect respectively). The
-optional/range-min reconciliation: a non-zero min forces the
argument to be present, so it contradicts -optional (which
permits absence) - declare -optional 0 for a min >= 1 range.
-multipleunique <bool> (only valid if -multiple is true)
If true, when multiple values are stored against the same argument
name due to -multiple being true, the values must be unique.
@ -3850,6 +3859,73 @@ tcl::namespace::eval punk::args {
#review - when using resolved_def to create a definiation based on another - OPT_MAX may need to be overridden - a bit ugly?
}
}
#G-053: canonicalize -multiple into internal companion fields while
#preserving the stored boolean's hot-path meaning (list-shape collect).
#Legacy 0/1 stay boolean; a 2-element {min max} list is the range form
#(max -1 = unbounded). The stored -multiple becomes the computed boolean
#so every existing truth-test (collect-vs-replace, scalar-vs-list,
#leader/value single-multiple rules) stays correct. The range companions
#(min/max/maxbounded) live in a SEPARATE FDICT MULTIPLE_RANGES dict keyed
#by argname - NOT in ARG_INFO, so they do not ride along when ARG_INFO is
#round-tripped as a spec via resolved_def copyfrom (the spec key validator
#would reject the internal keys).
set MULTIPLE_RANGES [tcl::dict::create]
foreach {argname arginfo} [tcl::dict::get $FDICT ARG_INFO] {
set rawmult [tcl::dict::get $arginfo -multiple]
#G-053: a 2-element {min max} list of integers (max may be -1) is the
#range form; everything else is the legacy boolean (0/1/true/false/yes/
#no/on/off, truth-tested as Tcl always did, coerced to 0/1 here so the
#stored -multiple is a clean boolean for the hot-path truth-tests).
set is_range 0
if {[llength $rawmult] == 2} {
set e0 [lindex $rawmult 0]
set e1 [lindex $rawmult 1]
if {[tcl::string::is integer -strict $e0] && ($e0 >= 0)
&& ([tcl::string::is integer -strict $e1] || $e1 eq "-1")} {
set is_range 1
}
}
if {$is_range} {
lassign $rawmult mmin mmax
if {$mmax != -1 && $mmax < 1} {
error "punk::args::resolve - bad -multiple range '$rawmult' for argument '$argname' in command form:'$fid'. The maximum (second element) must be a positive integer or -1 (unbounded). @id:$DEF_definition_id"
}
if {$mmax != -1 && $mmin > $mmax} {
error "punk::args::resolve - bad -multiple range '$rawmult' for argument '$argname' in command form:'$fid'. The minimum ($mmin) must not exceed the maximum ($mmax). @id:$DEF_definition_id"
}
if {$mmax == 1} {
set mbool 0 ;#scalar (at most once)
} else {
set mbool 1 ;#list (max>1 or unbounded)
}
set mbounded [expr {$mmax != -1}]
} else {
#legacy boolean - unlimited occurrences; coerce to 0/1
if {$rawmult} {
set mbool 1
} else {
set mbool 0
}
set mmin 0
set mmax -1
set mbounded 0
}
#G-053: -optional vs range-min reconciliation - a non-zero min forces
#presence (the arg must occur at least min times), so it contradicts
#-optional (which permits 0 occurrences). Reject with a clear message.
if {$mmin >= 1} {
set isoptional [tcl::dict::get $arginfo -optional]
if {$isoptional} {
error "punk::args::resolve - contradictory -multiple range '$rawmult' for argument '$argname' in command form:'$fid'. A minimum of $mmin occurrence(s) requires the argument to be present, but -optional is set (permits absence). Declare -optional 0 for this argument. @id:$DEF_definition_id"
}
}
dict set FDICT ARG_INFO $argname -multiple $mbool
if {$mbounded || $mmin > 0} {
dict set MULTIPLE_RANGES $argname [tcl::dict::create min $mmin max $mmax maxbounded $mbounded]
}
}
dict set FDICT MULTIPLE_RANGES $MULTIPLE_RANGES
# REVIEW
#no values specified - we can allow last leader to be multiple
foreach leadername [lrange [tcl::dict::get $FDICT LEADER_NAMES] 0 end-1] {
@ -6010,7 +6086,22 @@ tcl::namespace::eval punk::args {
set choicecolumns [Dict_getdef $arginfo -choicecolumns 4]
set choiceprefixdenylist [Dict_getdef $arginfo -choiceprefixdenylist {}]
set choiceprefixreservelist [Dict_getdef $arginfo -choiceprefixreservelist {}] ;#names used to calc prefix - but not available as actual choice.
if {[Dict_getdef $arginfo -multiple 0]} {
#G-053: Multi column reflects the declared -multiple range
#(e.g. "0-1", "2-4", "1+" for unbounded-with-floor). Legacy
#0/1 keep the greencheck/blank glyphs; is_multiple stays the
#stored boolean (list-shape) so the synopsis ?arg...? notation
#below is unaffected.
if {[tcl::dict::exists $form_dict MULTIPLE_RANGES $arg]} {
set _g53_mr [tcl::dict::get $form_dict MULTIPLE_RANGES $arg]
set _g53_mn [tcl::dict::get $_g53_mr min]
set _g53_mx [tcl::dict::get $_g53_mr max]
if {[tcl::dict::get $_g53_mr maxbounded]} {
set multiple "$_g53_mn-$_g53_mx"
} else {
set multiple "$_g53_mn+"
}
set is_multiple [Dict_getdef $arginfo -multiple 0]
} elseif {[Dict_getdef $arginfo -multiple 0]} {
set multiple $greencheck
set is_multiple 1
} else {
@ -6466,7 +6557,17 @@ tcl::namespace::eval punk::args {
if {[dict exists $arginfo -default]} {
append linetail " DEFAULT:[string map [list \n " "] $default]"
}
if {$is_multiple} {
#G-053: string renderer reflects the declared range
if {[tcl::dict::exists $form_dict MULTIPLE_RANGES $arg]} {
set _g53_mr [tcl::dict::get $form_dict MULTIPLE_RANGES $arg]
set _g53_mn [tcl::dict::get $_g53_mr min]
set _g53_mx [tcl::dict::get $_g53_mr max]
if {[tcl::dict::get $_g53_mr maxbounded]} {
append linetail " MULTI:$_g53_mn-$_g53_mx"
} else {
append linetail " MULTI:$_g53_mn+"
}
} elseif {$is_multiple} {
append linetail " MULTI:yes"
}
if {$hint ne ""} {
@ -7211,6 +7312,17 @@ tcl::namespace::eval punk::args {
#not end-of-input exhaustion: appending words cannot resolve it.
return invalid
}
occurrencecount {
#G-053: payload is <argname> count <cnt> min <mn> | max <mx>. An under-min
#occurrence (count < min) is pure end-of-input exhaustion - satisfiable by
#appending words - so the form is still viable (incomplete). An over-max
#occurrence (count > max) is a hard contradiction (too many supplied) -
#appending words cannot resolve it - so invalid.
if {"min" in $payload} {
return incomplete
}
return invalid
}
leadingvaluecount - trailingvaluecount {
#payload: <num> min <min> max <max>
set num [lindex $payload 0]
@ -10487,6 +10599,37 @@ tcl::namespace::eval punk::args {
#ending INSIDE a multi-member type clause raises (clause allocation cannot
#affirm the partial words) - such prefixes report non-viable even when the
#partial clause words match.
#G-053: occurrence-range enforcement for -multiple {min max} ranges. Called
#after each section's parse loop (opts/leaders/values). Checks every declared
#arg in $names against its MULTIPLE_RANGES entry (if any): over-supply beyond a
#bounded max raises occurrencecount with the max payload (a hard contradiction -
#fires in both normal and viability-probe modes); under-supply below a nonzero min
#raises occurrencecount with the min payload (pure end-of-input exhaustion -
#SUPPRESSED in the G-152 viability probe so a viable form reports incomplete
#rather than invalid). $received_list is the section's per-arg occurrence
#tracker (optsets_received / leadernames_received / valnames_received);
#$classlabel is the human label (Option/Leader/Value). Returns {} if ok, or a
#2-element list {errorcode-options error-message} to raise.
proc private::multiple_range_enforce {received_list names ranges argspecs classlabel {viabilitycheck 0}} {
if {![llength $ranges]} {return ""}
foreach argname $names {
if {![tcl::dict::exists $ranges $argname]} continue
set mr [tcl::dict::get $ranges $argname]
set cnt [llength [lsearch -all $received_list $argname]]
set mn [tcl::dict::get $mr min]
set mx [tcl::dict::get $mr max]
if {[tcl::dict::get $mr maxbounded] && $cnt > $mx} {
set msg "Bad arguments for %caller%. $classlabel $argname may be supplied at most $mx time(s) but received $cnt occurrence(s)."
return [list [list -code error -errorcode [list PUNKARGS VALIDATION [list occurrencecount $argname count $cnt max $mx] -badarg $argname -argspecs $argspecs]] $msg]
}
if {!$viabilitycheck && $mn > 0 && $cnt < $mn} {
set msg "Bad arguments for %caller%. $classlabel $argname requires at least $mn occurrence(s) but received $cnt."
return [list [list -code error -errorcode [list PUNKARGS VALIDATION [list occurrencecount $argname count $cnt min $mn] -badarg $argname -argspecs $argspecs]] $msg]
}
}
return ""
}
proc private::get_dict_form {argspecs fid rawargs {viabilitycheck 0}} {
#G-164: the probe's alternative-allocation re-probe invokes this proc with a
#2-element viabilitycheck {1 reseat_words} - words the valmin reservation
@ -10526,6 +10669,7 @@ tcl::namespace::eval punk::args {
#individual var extraction is faster than 'dict with' - even though we need nearly every key
set ARG_INFO [dict get $formdict ARG_INFO]
set ARG_CHECKS [dict get $formdict ARG_CHECKS]
set MULTIPLE_RANGES [Dict_getdef $formdict MULTIPLE_RANGES {}]
set LEADER_DEFAULTS [dict get $formdict LEADER_DEFAULTS]
set LEADER_REQUIRED [dict get $formdict LEADER_REQUIRED]
@ -11586,11 +11730,17 @@ tcl::namespace::eval punk::args {
#set values [list {*}$pre_values {*}$post_values]
set leaders $pre_values
set values $post_values
#G-053: occurrence-range enforcement for options (after the opts loop)
set _g53_e [private::multiple_range_enforce $optsets_received $OPT_NAMES $MULTIPLE_RANGES $argspecs Option $viabilitycheck]
if {[llength $_g53_e]} {lassign $_g53_e _g53_eo _g53_em; return -options $_g53_eo $_g53_em}
} else {
set leaders $pre_values
set values $remaining_rawargs
#set values [list {*}$pre_values {*}$remaining_rawargs] ;#no -flags detected
set arglist [list]
#G-053: occurrence-range enforcement for options (no-flags branch)
set _g53_e [private::multiple_range_enforce $optsets_received $OPT_NAMES $MULTIPLE_RANGES $argspecs Option $viabilitycheck]
if {[llength $_g53_e]} {lassign $_g53_e _g53_eo _g53_em; return -options $_g53_eo $_g53_em}
}
@ -11923,6 +12073,9 @@ tcl::namespace::eval punk::args {
}
#-----------------------------------------------------
#G-053: occurrence-range enforcement for leaders (after the leaders loop)
set _g53_e [private::multiple_range_enforce $leadernames_received $LEADER_NAMES $MULTIPLE_RANGES $argspecs Leader $viabilitycheck]
if {[llength $_g53_e]} {lassign $_g53_e _g53_eo _g53_em; return -options $_g53_eo $_g53_em}
set validx 0
set valname_multiple ""
@ -12169,6 +12322,10 @@ tcl::namespace::eval punk::args {
}
#-----------------------------------------------------
#G-053: occurrence-range enforcement for values (after the values loop)
set _g53_e [private::multiple_range_enforce $valnames_received $VAL_NAMES $MULTIPLE_RANGES $argspecs Value $viabilitycheck]
if {[llength $_g53_e]} {lassign $_g53_e _g53_eo _g53_em; return -options $_g53_eo $_g53_em}
#G-152 viability probe: a below-minimum count is pure end-of-input exhaustion -
#satisfiable by appending words - only while the parse position can still reach
#that section: the leaders section only when nothing was consumed beyond it (no

3
src/modules/punk/args-buildversion.txt

@ -1,6 +1,7 @@
0.21.0
0.22.0
#First line must be a semantic version number
#all other lines are ignored.
#0.22.0 - G-053 range-valued -multiple (occurrence arity): -multiple now accepts a {min max} range (max -1 = unbounded) alongside the legacy booleans 0/1, so a definition can declare at most once ({0 1}, a second occurrence is a parse error), bounded repetition ({2 4}), or one-or-more ({1 -1}) instead of choosing between silent last-wins (0) and unbounded collection (1). 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). 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). Resolve validation: max must be positive or -1, min <= max, and the -optional/range-min reconciliation - a non-zero min forces presence so -optional set is a contradiction (reject with a clear message; declare -optional 0). Parse enforcement: a new PUNKARGS VALIDATION occurrencecount failure class (payload <argname> count <n> min <m> | max <m>) fires in a single post-loop pass per section (opts/leaders/values); over-max is a hard contradiction (fires in both 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 and classified incomplete so a viable form reports incomplete, not invalid). The usage-table Multi column reflects the range (0-1 / 2-4 / 1+ for unbounded-with-floor; the 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 with max>1 ranges unchanged. define -help documents the range form and the -optional/range-min rule. New testsuite multipleranges.test (28 tests: 13 define-time canonicalisation+validation, 12 parse-time enforcement incl parse_status verdicts and legacy-required-still-trailingvaluecount guard, 3 rendering); full punk/args suite 399/0, punk/ns 125/125.
#0.21.0 - G-083 increment 3 (argument-relations usage rendering + lsearch moduledoc adoption): the -conflicts and -parsekeymode error vocabulary now surfaces in usage/arg_error and synopsis output, and the lsearch tclcore moduledoc models its documented option incompatibilities with the new vocabulary. Rendering: (a) a per-arg -conflicts list appends a 'conflicts with: <targets>' hint to the argument's help text in the usage table (targets resolved to display names via lookup_optset, so a parsekey target shows its member flag, not the raw parsekey); (b) a named @opts group marked -parsekeymode error is annotated 'mutually exclusive (distinct members may not be combined)' in its group header, while override (default) groups carry no such annotation; (c) the synopsis one-line form carries no conflict detail (conflicts are a usage-table concern, not a synopsis-line one). Characterized in relations.test (conflicts_usage_hint, parsekeymode_error_usage_group_header, parsekeymode_override_no_header_annotation, conflicts_synopsis_no_hint). lsearch moduledoc: -sorted gains -conflicts {-glob -regexp} and -bisect gains -conflicts {-all -not} (per-arg conflicts, the whole group stays -parsekeymode override so -glob/-regexp remain last-wins); the '(documentation incomplete - punk::args fixes required for grouped mutually exclusive options and prefix calculation)' caveat is dropped from the @cmd -help. Pinned in relations.test (lsearch_sorted_conflicts_glob + lsearch_bisect_conflicts_all raise optionconflict, lsearch_glob_regexp_last_wins stays last-wins, lsearch_caveat_dropped). clock clicks unchanged - its active positional-choice definition already models exclusivity (exactly-one-of) and the acceptance clause names lsearch only. Legacy untouched by default (no -conflicts/parsekeymode-error definition carries no new rendering). Full punk/args suite green; tclcoreparity 10/10.
#0.20.0 - G-083 increment 2 (argument-relations parse-time enforcement): the -conflicts and -parsekeymode error vocabulary declared at define time (0.19.0) is now enforced at parse. A new optionconflict failure class joins the PUNKARGS VALIDATION errorcode vocabulary, mirroring optionmissing's shape: {optionconflict <arg_a> <arg_b> received <receivednames>} - it names both offending received arguments, for both per-arg -conflicts violations (any pair, cross-group) and -parsekeymode error group co-occurrence (distinct members of a shared-parsekey group). The check runs in a single post-resolution site in get_dict_form (after the optionmissing/valuemissing block), so the ordinary option path and the mash (short-flag bundling) path share it - both raise identically. Checked against RECEIVED arguments only (defaults never conflict), after prefix/abbreviation resolution, on optset identity (a new optsets_received tracker for options, since flagsreceived collapses shared-parsekey members onto one api_opt). Runs unconditionally - a received conflict is a hard contradiction in any mode, not end-of-input exhaustion, so the candidacy/viability probe reports it as status invalid (parse_status_classify maps optionconflict to invalid, not incomplete). Define-time check added: a group marked -parsekeymode error must also declare a non-empty -parsekey (the strict mode only applies to a shared-parsekey group). Legacy untouched by default: -parsekeymode override is the default and absent -conflicts means no check runs, so the full existing suite (including the pinned parsekey_repeat_ordering last-wins / prepend-defaults idiom) passes unchanged. relations.test extended with 7 parse-time enforcement pins (raise cases, defaults-never-conflict, cross-group, parsekeymode error raise + one-received-ok + override-legacy, parse_status invalid); full punk/args suite green.
#0.19.0 - G-083 increment 1 (argument-relations define-time vocabulary): new per-argument key -conflicts <list> (parsekeys or flag names that must not be RECEIVED together with this argument; checked against received args only at parse time, defaults never conflict - parse-time optionconflict enforcement lands in a later G-083 increment) and new @opts-level -parsekeymode override|error (per named OPT_GROUPS group; error = distinct-member co-occurrence within a shared-parsekey group raises optionconflict at parse; override = legacy last-wins, the default). Both keys are cross-validated at resolve: -conflicts targets must name a defined argname or declared -parsekey; -parsekeymode requires -group and a value of override|error. Define-time integrity hole closed: a -parsekey value colliding with a distinct defined argument's name (one that does not share that -parsekey) is now a resolve error instead of silently forming an implicit shared-key group (parsekey_collides_with_defined_optname_GAP flipped to a define-time error pin in parsekey.test). Legacy behaviour untouched by default (no -conflicts, default -parsekeymode override, no colliding parsekey). New testsuite relations.test pins the define-time vocabulary; full punk/args suite green.

2
src/tests/modules/AGENTS.md

File diff suppressed because one or more lines are too long

355
src/tests/modules/punk/args/testsuites/args/multipleranges.test

@ -0,0 +1,355 @@
package require tcltest
package require punk::args
#G-053 range-valued -multiple: occurrence arity with strict duplicate handling.
#This file pins the DEFINE-TIME vocabulary (spec-compiler canonicalisation and
#validation). The parse-time enforcement (max-occurrence error, min-occurrence
#arithmetic) is pinned here too once implemented (increment 2). Error-shape pins
#use distinctive message substrings for resolve-time definition errors (plain
#text, no ANSI) - the relations.test/longopts.test convention. Definitions are
#lazy: resolve errors surface at first parse, so the parse path is used to
#trigger them and the message substring is pinned. Observable canonicalised
#state (the stored boolean + the MULTIPLE_RANGES companion dict) is read via
#punk::args::define + get_spec.
namespace eval ::testspace {
namespace import ::tcltest::*
variable common {
set result ""
}
#--------------------------------------------------------------------
# increment 1: define-time vocabulary (canonicalisation + validation)
#--------------------------------------------------------------------
test multirange_legacy_zero_unchanged {legacy -multiple 0 stays scalar replace (byte-unchanged)}\
-setup $common -body {
set argd [punk::args::parse {-x 1 -x 2} withdef @opts {-x -type int}]
lappend result [dict get $argd opts -x]
set result
} -result [list 2]
test multirange_legacy_one_unchanged {legacy -multiple 1 stays list collect (byte-unchanged)}\
-setup $common -body {
set argd [punk::args::parse {-x 1 -x 2} withdef @opts {-x -type int -multiple 1}]
lappend result [dict get $argd opts -x]
set result
} -result [list {1 2}]
test multirange_legacy_boolstring_true {legacy -multiple true coerces to collect like 1}\
-setup $common -body {
set argd [punk::args::parse {-x 1 -x 2} withdef @opts {-x -type int -multiple true}]
lappend result [dict get $argd opts -x]
set result
} -result [list {1 2}]
test multirange_legacy_boolstring_false {legacy -multiple false coerces to scalar replace like 0}\
-setup $common -body {
set argd [punk::args::parse {-x 1 -x 2} withdef @opts {-x -type int -multiple false}]
lappend result [dict get $argd opts -x]
set result
} -result [list 2]
test multirange_range_0_1_canonical_scalar {range {0 1} canonicalises to scalar bool 0 with a maxbounded companion}\
-setup $common -body {
punk::args::define {
@id -id ::g53r1
@opts
-x -type none -multiple {0 1}
@values -min 0 -max 0
}
set spec [punk::args::get_spec ::g53r1]
set f0 [lindex [dict keys [dict get $spec FORMS]] 0]
set ai [dict get $spec FORMS $f0 ARG_INFO]
lappend result [dict get $ai -x -multiple]
lappend result [dict get $spec FORMS $f0 MULTIPLE_RANGES -x]
set result
} -result [list 0 {min 0 max 1 maxbounded 1}]
test multirange_range_2_4_canonical_list {range {2 4} canonicalises to list bool 1 with a bounded companion}\
-setup $common -body {
punk::args::define {
@id -id ::g53r2
@opts
-x -type int -multiple {2 4} -optional 0
@values -min 0 -max 0
}
set spec [punk::args::get_spec ::g53r2]
set f0 [lindex [dict keys [dict get $spec FORMS]] 0]
set ai [dict get $spec FORMS $f0 ARG_INFO]
lappend result [dict get $ai -x -multiple]
lappend result [dict get $spec FORMS $f0 MULTIPLE_RANGES -x]
set result
} -result [list 1 {min 2 max 4 maxbounded 1}]
test multirange_range_1_unbounded_canonical_list {range {1 -1} canonicalises to list bool 1, unbounded, floor 1}\
-setup $common -body {
punk::args::define {
@id -id ::g53r3
@opts
-x -type int -multiple {1 -1} -optional 0
@values -min 0 -max 0
}
set spec [punk::args::get_spec ::g53r3]
set f0 [lindex [dict keys [dict get $spec FORMS]] 0]
set ai [dict get $spec FORMS $f0 ARG_INFO]
lappend result [dict get $ai -x -multiple]
lappend result [dict get $spec FORMS $f0 MULTIPLE_RANGES -x]
set result
} -result [list 1 {min 1 max -1 maxbounded 0}]
test multirange_range_0_unbounded_equivalent_legacy_one {range {0 -1} is unbounded collect with no floor - no companion (equivalent to legacy 1)}\
-setup $common -body {
punk::args::define {
@id -id ::g53r4
@opts
-x -type int -multiple {0 -1}
@values -min 0 -max 0
}
set spec [punk::args::get_spec ::g53r4]
set f0 [lindex [dict keys [dict get $spec FORMS]] 0]
set ai [dict get $spec FORMS $f0 ARG_INFO]
lappend result [dict get $ai -x -multiple]
lappend result [dict exists [dict get $spec FORMS $f0 MULTIPLE_RANGES] -x]
set result
} -result [list 1 0]
test multirange_bad_max_zero {range max of 0 is a resolve error (max must be positive or -1)}\
-setup $common -body {
try {
punk::args::parse {} withdef @opts {-x -type none -multiple {0 0}}
lappend result "UNEXPECTED-accepted"
} on error {emsg eopts} {
lappend result [string match "*maximum (second element) must be a positive integer or -1*" $emsg]
}
set result
} -result [list 1]
test multirange_bad_min_gt_max {range min > max is a resolve error}\
-setup $common -body {
try {
punk::args::parse {} withdef @opts {-x -type none -multiple {3 2}}
lappend result "UNEXPECTED-accepted"
} on error {emsg eopts} {
lappend result [string match "*minimum (3) must not exceed the maximum (2)*" $emsg]
}
set result
} -result [list 1]
test multirange_optional_vs_min_reconciliation {range min >= 1 with -optional set (opts default) is a resolve error}\
-setup $common -body {
try {
#opts default -optional 1; {1 -1} min 1 forces presence -> contradiction
punk::args::parse {} withdef @opts {-x -type none -multiple {1 -1}}
lappend result "UNEXPECTED-accepted"
} on error {emsg eopts} {
lappend result [string match "*contradictory -multiple range*requires the argument to be present, but -optional is set*" $emsg]
}
set result
} -result [list 1]
test multirange_optional_zero_with_optional_ok {range {0 1} with -optional (default) is fine - min 0 permits absence}\
-setup $common -body {
punk::args::parse {} withdef @opts {-x -type none -multiple {0 1}}
lappend result "ok"
set result
} -result [list ok]
test multirange_multipleunique_composes {range {2 4} with -multipleunique resolves (composes unchanged)}\
-setup $common -body {
punk::args::define {
@id -id ::g53r5
@opts
-x -type int -multiple {2 4} -optional 0 -multipleunique 1
@values -min 0 -max 0
}
set spec [punk::args::get_spec ::g53r5]
set f0 [lindex [dict keys [dict get $spec FORMS]] 0]
set ai [dict get $spec FORMS $f0 ARG_INFO]
lappend result [dict get $ai -x -multiple]
lappend result [dict get $ai -x -multipleunique]
set result
} -result [list 1 1]
#--------------------------------------------------------------------
# increment 2: parse-time enforcement (occurrencecount)
#--------------------------------------------------------------------
test multirange_opt_at_most_once_second_errors {option {0 1}: a second occurrence raises occurrencecount max}\
-setup $common -body {
try {
punk::args::parse {-x 1 -x 2} withdef @opts {-x -type int -multiple {0 1}}
lappend result "UNEXPECTED-accepted"
} on error {emsg eopts} {
lappend result [string match "*PUNKARGS VALIDATION*occurrencecount -x*max 1*" [dict get $eopts -errorcode]]
}
set result
} -result [list 1]
test multirange_opt_at_most_once_first_ok {option {0 1}: a single occurrence parses (scalar)}\
-setup $common -body {
set argd [punk::args::parse {-x 1} withdef @opts {-x -type int -multiple {0 1}}]
lappend result [dict get $argd opts -x]
set result
} -result [list 1]
test multirange_opt_bounded_under_min_errors {option {2 4}: one occurrence raises occurrencecount min}\
-setup $common -body {
try {
punk::args::parse {-x 1} withdef @opts {-x -type int -multiple {2 4} -optional 0}
lappend result "UNEXPECTED-accepted"
} on error {emsg eopts} {
lappend result [string match "*PUNKARGS VALIDATION*occurrencecount -x*min 2*" [dict get $eopts -errorcode]]
}
set result
} -result [list 1]
test multirange_opt_bounded_in_range_ok {option {2 4}: three occurrences parse (collected list)}\
-setup $common -body {
set argd [punk::args::parse {-x 1 -x 2 -x 3} withdef @opts {-x -type int -multiple {2 4} -optional 0}]
lappend result [dict get $argd opts -x]
set result
} -result [list {1 2 3}]
test multirange_opt_bounded_over_max_errors {option {2 4}: five occurrences raise occurrencecount max}\
-setup $common -body {
try {
punk::args::parse {-x 1 -x 2 -x 3 -x 4 -x 5} withdef @opts {-x -type int -multiple {2 4} -optional 0}
lappend result "UNEXPECTED-accepted"
} on error {emsg eopts} {
lappend result [string match "*PUNKARGS VALIDATION*occurrencecount -x*max 4*" [dict get $eopts -errorcode]]
}
set result
} -result [list 1]
test multirange_opt_floor_unbounded_one_ok {option {1 -1}: one occurrence parses (min satisfied)}\
-setup $common -body {
set argd [punk::args::parse {-x 1} withdef @opts {-x -type int -multiple {1 -1} -optional 0}]
lappend result [dict get $argd opts -x]
set result
} -result [list 1]
test multirange_opt_floor_unbounded_zero_errors {option {1 -1}: zero occurrences raise occurrencecount min}\
-setup $common -body {
try {
punk::args::parse {} withdef @opts {-x -type int -multiple {1 -1} -optional 0}
lappend result "UNEXPECTED-accepted"
} on error {emsg eopts} {
lappend result [string match "*PUNKARGS VALIDATION*occurrencecount -x*min 1*" [dict get $eopts -errorcode]]
}
set result
} -result [list 1]
test multirange_value_bounded_enforced {value {2 4}: under-min errors, in-range ok, over-max errors}\
-setup $common -body {
if {[catch {punk::args::parse {1} withdef @values {v -type int -multiple {2 4}}}]} {
lappend result under-err
} else {
lappend result under-ok
}
set argd [punk::args::parse {1 2 3} withdef @values {v -type int -multiple {2 4}}]
lappend result in-ok
lappend result [dict get $argd values v]
if {[catch {punk::args::parse {1 2 3 4 5} withdef @values {v -type int -multiple {2 4}}}]} {
lappend result over-err
} else {
lappend result over-ok
}
set result
} -result [list under-err in-ok {1 2 3} over-err]
test multirange_leader_bounded_enforced {leader {0 2}: at most 2 occurrences enforced}\
-setup $common -body {
set argd [punk::args::parse {1 2 3} withdef @leaders {a -type int} {b -type int -multiple {0 2}} @values]
lappend result [dict get $argd leaders b]
try {
punk::args::parse {1 2 3 4 5} withdef @leaders {a -type int} {b -type int -multiple {0 2}} @values
lappend result "over:accepted"
} on error {emsg eopts} {
lappend result "over:err"
}
set result
} -result [list {2 3} {over:err}]
test multirange_parsestatus_undermin_incomplete {parse_status: under-min occurrence is incomplete (viable, pure exhaustion)}\
-setup $common -body {
set st [punk::args::parse_status {1} withdef @values {v -type int -multiple {2 4}}]
lappend result [dict get $st status]
lappend result [dict get $st failureclass]
set result
} -result [list incomplete occurrencecount]
test multirange_parsestatus_overmax_invalid {parse_status: over-max occurrence is invalid (hard contradiction)}\
-setup $common -body {
set st [punk::args::parse_status {1 2 3 4 5} withdef @values {v -type int -multiple {2 4}}]
lappend result [dict get $st status]
lappend result [dict get $st failureclass]
set result
} -result [list invalid occurrencecount]
test multirange_legacy_required_still_missingrequired {legacy -multiple 1 required value with 0 occurrences still reports the count shortfall (byte-unchanged, not occurrencecount)}\
-setup $common -body {
try {
punk::args::parse {} withdef @values {v -type int -multiple 1}
lappend result "UNEXPECTED-accepted"
} on error {emsg eopts} {
lappend result [lrange [dict get $eopts -errorcode] 0 2]
}
set result
} -result [list {PUNKARGS VALIDATION {trailingvaluecount 0 min 1 max -1}}]
#--------------------------------------------------------------------
# increment 3: rendering (Multi column + synopsis reflect declared ranges)
#--------------------------------------------------------------------
test multirange_render_multi_column_ranges {usage table Multi column shows 0-1 / 2-4 / 1+ for ranges, greencheck for legacy 1}\
-setup $common -body {
punk::args::define {
@id -id ::g53r_multi
@opts
-x -type int -multiple {0 1}
-y -type int -multiple {2 4} -optional 0
-z -type int -multiple {1 -1} -optional 0
-w -type int -multiple 1
@values -min 0 -max 0
}
#trigger a usage render via a parse error; strip ANSI from the rendered info
catch {punk::args::parse {--bogus} withid ::g53r_multi} m eopts
set plain [join [lmap l [split [dict get $eopts -errorinfo] \n] {regsub -all {\x1b\[[0-9;]*m} $l ""}] \n]
lappend result [regexp {0-1} $plain]
lappend result [regexp {2-4} $plain]
lappend result [regexp {1\+} $plain]
lappend result [regexp {\xe2\x9c\x93|\u2713} $plain]
set result
} -result [list 1 1 1 1]
test multirange_render_synopsis_atmostonce_no_ellipsis {synopsis: {0 1} optional arg shows ?-x? with no ... ellipsis (at most once)}\
-setup $common -body {
punk::args::define {
@id -id ::g53r_syn1
@opts
-x -type int -multiple {0 1}
@values -min 0 -max 0
}
set s [punk::args::synopsis -return full ::g53r_syn1]
#optional at-most-once: ?-x <int>? and NO trailing ... for -x
lappend result [string match {*?-x*?*} $s]
lappend result [expr {[string match {*-x*...*} $s] ? 0 : 1}]
set result
} -result [list 1 1]
test multirange_render_synopsis_bounded_repeats_ellipsis {synopsis: {2 4} required arg carries the ... repetition ellipsis}\
-setup $common -body {
punk::args::define {
@id -id ::g53r_syn2
@opts
-y -type int -multiple {2 4} -optional 0
@values -min 0 -max 0
}
set s [punk::args::synopsis -return full ::g53r_syn2]
lappend result [string match {*-y*...*} $s]
set result
} -result [list 1]
}
tcltest::cleanupTests
Loading…
Cancel
Save