Browse Source

punk::args 0.25.0: bounded -multiple ranges participate in leader/value allocation (directed work post-G-053; project 0.62.0)

Bounded -multiple occurrence ranges now PARTICIPATE in positional allocation
for leaders and values instead of being post-loop enforcement only.
Previously greedy collection overran a bounded max unless a type screen
happened to stop it - untyped {3 3} followed by a -multiple tail collected 4
words and occurrencecount then reported the overrun the allocation itself
created (and typed {3 3} with four consecutive ints failed identically) -
and a later REQUIRED ranged argument was starved to a single reserved
clause.

Three cooperating sites:

(a) get_dict_can_assign_value caps a satisfied -multiple argument at its
bounded max: the word is yielded (no-consume) so the caller's retreat
advances to the next argument - one proc serving both the leaders and
values loops. The yield carries a G-082 rejection record of new kind
'occurrence', and both loops' overflow selection sites render it as the
pointed occurrence-limit report with the G-053 occurrencecount errorcode
(count = the attempted occurrence) instead of the generic toomanyarguments,
so genuine over-supply keeps its pointed class - now describing an overflow
the allocator refused rather than one it created.

(b) Reservations are min-occurrence aware: the allocator's tail_needs and
the derived valmin floor (leader/value split + option-scan reservation)
reserve min-occurrences x min-clause-length for a required ranged argument
- a following required -multiple {3 3} reserves 3 clauses, {2 2} pair
clauses reserve 4 words - so earlier greed cannot starve it. An explicit
'@values -min' still overrides the derived floor; legacy boolean -multiple
(no MULTIPLE_RANGES entry) reserves exactly as before.

(c) The greedy leader scan caps a bounded -multiple last leader at max
occurrences (via the leader_posn_names_assigned counter), so remaining
words flow to the opts/values sections. (The split is a separate pre-loop
scan - capping only the loops left over-provided leader words raising
toomanyarguments.)

First occurrences are never capped (resolve guarantees range max >= 1);
{0 1} at-most-once scalars are unaffected (no collection); bounded-variable
ranges take greedy-up-to-max deterministically; legacy boolean greed is
byte-unchanged (pinned). define -help -multiple documents the allocation
semantics.

multipleranges.test gains 7 allocation pins: untyped/typed {3 3} cap incl
the cap-beats-type-screen four-ints case, greedy-up-to-max {1 2},
required-range and pair-clause reservations, the leaders-side scan cap +
split floor, the pointed over-supply report, and a legacy-greed-unchanged
guard. src/tests/modules/AGENTS.md index updated.

Project 0.61.0 -> 0.62.0 + CHANGELOG (allocation behaviour is user-visible
shell parsing). punk::args 0.24.0 -> 0.25.0.

Suites: punk/args 408/0; modules tree 1320 pass / 11 constraint-skipped /
0 fail (zig-built tclsh90s 9.0.5); testbody_lint clean; make.tcl
projectversion consistency OK.

Claude-Session: https://claude.ai/code/session_01QgaxV27VZkmEec7oNbEVFc
Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com
master
Julian Noble 2 days ago
parent
commit
df74c3b2db
  1. 16
      CHANGELOG.md
  2. 2
      punkproject.toml
  3. 93
      src/modules/punk/args-999999.0a1.0.tm
  4. 3
      src/modules/punk/args-buildversion.txt
  5. 2
      src/tests/modules/AGENTS.md
  6. 79
      src/tests/modules/punk/args/testsuites/args/multipleranges.test

16
CHANGELOG.md

@ -5,6 +5,22 @@ 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` Entries are newest-first; one bullet per notable change. See the root `AGENTS.md`
"Project Versioning" section for the bump policy. "Project Versioning" section for the bump policy.
## [0.62.0] - 2026-08-08
- `punk::args` bounded `-multiple` occurrence ranges now participate in
positional allocation (directed work, post-G-053): a leader or value
argument that has taken its max occurrences yields further words to later
arguments (an untyped `{3 3}` followed by a `-multiple` tail takes exactly
3 instead of overrunning and erroring), a later REQUIRED ranged argument
reserves min-occurrences worth of words from earlier greedy collection
(the allocator reservation, the derived valmin floor for the leader/value
split and option scan - an explicit `@values -min` still overrides), and
the greedy leader scan caps a bounded `-multiple` last leader. Genuine
over-supply keeps a pointed report: the overflow site renders the
occurrence limit with the G-053 `occurrencecount` errorcode via a new
G-082 rejection kind. Legacy boolean `-multiple` allocation is unchanged.
(punk::args 0.25.0)
## [0.61.0] - 2026-08-07 ## [0.61.0] - 2026-08-07
- `punk::args` `-parsekey` completeness (G-084): a VALUE argument's - `punk::args` `-parsekey` completeness (G-084): a VALUE argument's

2
punkproject.toml

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

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

@ -1040,6 +1040,16 @@ tcl::namespace::eval punk::args {
-optional/range-min reconciliation: a non-zero min forces the -optional/range-min reconciliation: a non-zero min forces the
argument to be present, so it contradicts -optional (which argument to be present, so it contradicts -optional (which
permits absence) - declare -optional 0 for a min >= 1 range. permits absence) - declare -optional 0 for a min >= 1 range.
Bounded ranges participate in positional ALLOCATION for
leaders and values: an argument that has taken its max
occurrences yields further words to later arguments
(greedy-up-to-max - so an untyped {3 3} followed by a
-multiple tail takes exactly 3), and a later REQUIRED
argument with a range minimum reserves min occurrences'
worth of words from earlier greedy collection (an explicit
'@values -min' overrides the derived reservation floor).
A word overflowing a bounded max that fits no later
argument reports the pointed occurrence limit.
-multipleunique <bool> (only valid if -multiple is true) -multipleunique <bool> (only valid if -multiple is true)
If true, when multiple values are stored against the same argument If true, when multiple values are stored against the same argument
name due to -multiple being true, the values must be unique. name due to -multiple being true, the values must be unique.
@ -8967,6 +8977,28 @@ tcl::namespace::eval punk::args {
set thistype [dict get $ARG_INFO $thisname -type] set thistype [dict get $ARG_INFO $thisname -type]
set tailnames [lrange $names $nameidx+1 end] set tailnames [lrange $names $nameidx+1 end]
#G-053 allocation participation (directed work 2026-08-08): a BOUNDED
#-multiple occurrence range caps greedy collection - once this argument has
#taken max occurrences it cannot take another, so return no-consume and let
#the caller's retreat advance to the next argument (the same yield path as a
#failed type screen). Previously ranges were post-loop enforcement only:
#greedy collection overran a bounded max whenever the type screen could not
#stop it, and occurrencecount then reported the overrun allocation itself
#created (untyped {3 3} followed by a -multiple tail collected 4). A first
#occurrence is never capped (resolve guarantees range max >= 1). Serves both
#the leaders and values loops (shared proc).
if {[dict get $ARG_INFO $thisname -multiple] && $thisname in $namesreceived
&& [dict exists $formdict MULTIPLE_RANGES $thisname]} {
set mrange [dict get $formdict MULTIPLE_RANGES $thisname]
if {[dict get $mrange maxbounded]
&& [llength [lsearch -all -exact $namesreceived $thisname]] >= [dict get $mrange max]} {
#the G-082 rejection record lets an overflow raise report the pointed
#occurrence limit (kind 'occurrence') instead of a generic overflow
set rejection [dict create kind occurrence argname $thisname word [lindex $all_remaining 0] max [dict get $mrange max]]
return [dict create consumed 0 resultlist {} typelist $thistype rejection $rejection]
}
}
#todo - work backwards with any (optional or not) literals at tail that match our values - and remove from assignability. #todo - work backwards with any (optional or not) literals at tail that match our values - and remove from assignability.
set ridx 0 set ridx 0
#puts "-=============- thisname:'$thisname' thistype:'$thistype' tailnames:'$tailnames' all_remaining:'$all_remaining' [info level -2]" #puts "-=============- thisname:'$thisname' thistype:'$thistype' tailnames:'$tailnames' all_remaining:'$all_remaining' [info level -2]"
@ -9197,15 +9229,24 @@ tcl::namespace::eval punk::args {
set num_remaining [llength $all_remaining] set num_remaining [llength $all_remaining]
if {[dict get $ARG_INFO $thisname -optional] || ([dict get $ARG_INFO $thisname -multiple] && $thisname in $namesreceived)} { if {[dict get $ARG_INFO $thisname -optional] || ([dict get $ARG_INFO $thisname -multiple] && $thisname in $namesreceived)} {
#2026-07-14 Agent-Updated: occurrence min/max for -multiple is goal G-053 #thisname already satisfied, or not required - reserve words for later
#(proposed: -multiple accepts a {min max} range alongside the legacy booleans). #REQUIRED arguments so greed here cannot starve them.
#Not implemented - only boolean -multiple semantics apply here.
#thisname already satisfied, or not required
set tail_needs 0 set tail_needs 0
foreach t $tailnames { foreach t $tailnames {
if {![dict get $ARG_INFO $t -optional]} { if {![dict get $ARG_INFO $t -optional]} {
set min_clause_length [llength [lsearch -all -not [dict get $ARG_INFO $t -type] {\?*\?}]] set min_clause_length [llength [lsearch -all -not [dict get $ARG_INFO $t -type] {\?*\?}]]
incr tail_needs $min_clause_length #G-053 allocation participation (directed work 2026-08-08): a
#required argument with a range minimum needs min OCCURRENCES
#reserved, not one - e.g a following required -multiple {3 3}
#reserves 3 clauses' worth of words. (Legacy boolean -multiple
#has no MULTIPLE_RANGES entry; an -optional arg can never carry
#min >= 1 - resolve rejects the contradiction.)
set t_occurrences 1
if {[dict exists $formdict MULTIPLE_RANGES $t]} {
set t_occurrences [dict get $formdict MULTIPLE_RANGES $t min]
if {$t_occurrences < 1} {set t_occurrences 1}
}
incr tail_needs [expr {$t_occurrences * $min_clause_length}]
} }
} }
set all_remaining [lrange $all_remaining 0 end-$tail_needs] set all_remaining [lrange $all_remaining 0 end-$tail_needs]
@ -10920,7 +10961,17 @@ tcl::namespace::eval punk::args {
incr clause_length incr clause_length
} }
} }
incr valmin $clause_length #G-053 allocation participation (directed work 2026-08-08): a required
#value with a range minimum contributes min OCCURRENCES to the floor,
#not one - so the leader/value split and the option-scan reservation
#keep enough words back for e.g a required -multiple {3 3} value.
#(An explicit '@values -min' overrides this whole derivation.)
set v_occurrences 1
if {[dict exists $MULTIPLE_RANGES $vname]} {
set v_occurrences [dict get $MULTIPLE_RANGES $vname min]
if {$v_occurrences < 1} {set v_occurrences 1}
}
incr valmin [expr {$v_occurrences * $clause_length}]
} }
} else { } else {
set valmin $VAL_MIN set valmin $VAL_MIN
@ -11006,6 +11057,19 @@ tcl::namespace::eval punk::args {
} else { } else {
set leader_posn_name [lindex $LEADER_NAMES $nameidx] ;#may return empty string set leader_posn_name [lindex $LEADER_NAMES $nameidx] ;#may return empty string
} }
#G-053 allocation participation (directed work 2026-08-08): a
#bounded -multiple range on the (last) leader caps the leader
#scan - once max occurrences are assigned, remaining words belong
#to the opts/values sections rather than overrunning the leader.
if {$is_multiple && $leader_posn_name ne ""
&& [dict exists $MULTIPLE_RANGES $leader_posn_name]
&& [dict exists $leader_posn_names_assigned $leader_posn_name]} {
set lmr [dict get $MULTIPLE_RANGES $leader_posn_name]
if {[dict get $lmr maxbounded]
&& [dict get $leader_posn_names_assigned $leader_posn_name] >= [dict get $lmr max]} {
break
}
}
if {$OPT_MAX ne "0" && [string match -* $raw]} { if {$OPT_MAX ne "0" && [string match -* $raw]} {
#all_opts includes end_of_opts marker -- if configured - no need to explicitly check for it separately #all_opts includes end_of_opts marker -- if configured - no need to explicitly check for it separately
set possible_flagname $raw set possible_flagname $raw
@ -12230,6 +12294,14 @@ tcl::namespace::eval punk::args {
set rj_name [dict get $rj name] set rj_name [dict get $rj name]
set msg [private::unavailable_choice_msg $rj_argclass $rj_argname $ldr $rj_name [Dict_getdef $argstate $rj_argname -choicelabels {}] [dict get $rj choices]] set msg [private::unavailable_choice_msg $rj_argclass $rj_argname $ldr $rj_name [Dict_getdef $argstate $rj_argname -choicelabels {}] [dict get $rj choices]]
return -options [list -code error -errorcode [list PUNKARGS VALIDATION [list choiceunavailable $rj_name choices [dict get $rj choices]] -badarg $rj_argname -badval $ldr -argspecs $argspecs]] $msg return -options [list -code error -errorcode [list PUNKARGS VALIDATION [list choiceunavailable $rj_name choices [dict get $rj choices]] -badarg $rj_argname -badval $ldr -argspecs $argspecs]] $msg
} elseif {[dict get $rj kind] eq "occurrence"} {
#G-053 allocation participation: the argument reached its
#bounded -multiple max and this word overflowed - report the
#occurrence limit (same errorcode class as post-loop
#enforcement; count is the attempted occurrence)
set rj_max [dict get $rj max]
set msg "$rj_argclass '$rj_argname' for %caller% accepts at most $rj_max occurrence(s). Received extra word: '$ldr'"
return -options [list -code error -errorcode [list PUNKARGS VALIDATION [list occurrencecount $rj_argname count [expr {$rj_max + 1}] max $rj_max] -badarg $rj_argname -badval $ldr -argspecs $argspecs]] $msg
} else { } else {
set rj_type [dict get $rj type] set rj_type [dict get $rj type]
set msg "$rj_argclass '$rj_argname' for %caller% requires type '$rj_type'. Received: '$ldr'" set msg "$rj_argclass '$rj_argname' for %caller% requires type '$rj_type'. Received: '$ldr'"
@ -12487,6 +12559,15 @@ tcl::namespace::eval punk::args {
set rj_name [dict get $rj name] set rj_name [dict get $rj name]
set msg [private::unavailable_choice_msg $rj_argclass $rj_argname $val $rj_name [Dict_getdef $argstate $rj_argname -choicelabels {}] [dict get $rj choices]] set msg [private::unavailable_choice_msg $rj_argclass $rj_argname $val $rj_name [Dict_getdef $argstate $rj_argname -choicelabels {}] [dict get $rj choices]]
return -options [list -code error -errorcode [list PUNKARGS VALIDATION [list choiceunavailable $rj_name choices [dict get $rj choices]] -badarg $rj_argname -badval $val -argspecs $argspecs]] $msg return -options [list -code error -errorcode [list PUNKARGS VALIDATION [list choiceunavailable $rj_name choices [dict get $rj choices]] -badarg $rj_argname -badval $val -argspecs $argspecs]] $msg
} elseif {[dict get $rj kind] eq "occurrence"} {
#G-053 allocation participation: the argument reached its
#bounded -multiple max and this word overflowed - report the
#occurrence limit (same errorcode class as post-loop
#enforcement; count is the attempted occurrence)
#(MAINTENANCE - same selection logic as leaders loop above)
set rj_max [dict get $rj max]
set msg "$rj_argclass '$rj_argname' for %caller% accepts at most $rj_max occurrence(s). Received extra word: '$val'"
return -options [list -code error -errorcode [list PUNKARGS VALIDATION [list occurrencecount $rj_argname count [expr {$rj_max + 1}] max $rj_max] -badarg $rj_argname -badval $val -argspecs $argspecs]] $msg
} else { } else {
set rj_type [dict get $rj type] set rj_type [dict get $rj type]
set msg "$rj_argclass '$rj_argname' for %caller% requires type '$rj_type'. Received: '$val'" set msg "$rj_argclass '$rj_argname' for %caller% requires type '$rj_type'. Received: '$val'"

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

@ -1,6 +1,7 @@
0.24.0 0.25.0
#First line must be a semantic version number #First line must be a semantic version number
#all other lines are ignored. #all other lines are ignored.
#0.25.0 - G-053 allocation participation (directed work, post-G-053): bounded -multiple occurrence ranges now PARTICIPATE in positional allocation for leaders and values instead of being post-loop enforcement only. Previously greedy collection overran a bounded max unless a type screen happened to stop it (untyped {3 3} followed by a -multiple tail collected 4 words and occurrencecount then reported the overrun the allocation itself created), and a later REQUIRED ranged argument was starved to a single reserved clause. Three cooperating sites: (a) get_dict_can_assign_value caps a satisfied -multiple argument at its bounded max - it yields the word (no-consume) so the caller's retreat advances to the next argument, serving both the leaders and values loops; the yield carries a G-082 rejection record of new kind 'occurrence', and both loops' overflow selection sites render it as the pointed occurrence-limit report with the G-053 occurrencecount errorcode (count = the attempted occurrence) instead of the generic toomanyarguments - so genuine over-supply keeps its pointed class; (b) reservations are min-occurrence aware - the allocator's tail_needs and the derived valmin floor (leader/value split + option-scan reservation) reserve min-occurrences x min-clause-length for a required ranged argument (e.g a following required -multiple {3 3} reserves 3 clauses; {2 2} pair clauses reserve 4 words); an explicit '@values -min' still overrides the derived floor, and legacy boolean -multiple (no MULTIPLE_RANGES entry) reserves exactly as before; (c) the greedy leader scan caps a bounded -multiple last leader at max occurrences (tracked via leader_posn_names_assigned) so remaining words flow to the opts/values sections. First occurrences are never capped (resolve guarantees range max >= 1); {0 1} at-most-once scalars are unaffected (no collection). define -help -multiple documents the allocation semantics. New multipleranges.test allocation pins: untyped/typed {3 3} cap incl the cap-beats-type-screen 4-ints case, greedy-up-to-max {1 2}, required-range and pair-clause reservations, leaders-side cap + split floor, pointed over-supply report, legacy-greed-unchanged guard. Full punk/args suite 408/0.
#0.24.0 - G-084 increment 2 (parsekey completeness, part 2 - the settled cross-member design + integrity closes): -multiple on a member of a shared-parsekey OPTION group is now a DEFINE-TIME error unless ALL members belong to one @opts group marked -parsekeymode error (whose G-083 mutual exclusivity makes per-member collection well-defined; a single -multiple member with its own parsekey is unrestricted). This settles the G-084 cross-member accumulation design decision as rejection rather than accumulate-in-received-order: cross-member collection on one storage key is ambiguous (collect-vs-replace undefined; one member's collected list would silently displace another's). The check runs AFTER the G-053 -multiple canonicalisation so range forms get correct verdicts: {0 1} is replace-shape and allowed on a shared group, collect shapes (boolean 1, max>1, unbounded) are rejected. Value -parsekey integrity settled at define time: duplicate value parsekeys are rejected (trailing values consume positionally so distinct values sharing one result slot silently overwrote each other), and a dash-led value parsekey is rejected (it landed in the options namespace of the result/received dicts and ABORTED parse with an internal error in the option-shaped validation paths). parse_status/parse_report storage-key attribution: parse_status_build now resolves a storage key claimed by exactly ONE argument (a value's -parsekey, an option's declared -parsekey or its '='-suffixed trimmed tail) back to that argument, so argstatus/Source report received with the value-in-effect instead of default/0; shared-parsekey group members stay unresolved (per-member attribution does not survive the storage fold - the remaining recorded G-084 display gap, needs engine-level per-member tracking in the parse result; parse_report's display bridge still shows the group value on each member row). undefine/undefine_deflist now return "" (previously returned the final 'dict unset' expression - the WHOLE cache dict, hundreds of KB, to any caller capturing the value). define -help now documents -parsekey (storage-key rename for options and values, leader rejection, shared groups and -parsekeymode interaction, last-defined-member-wins defaults precedence, the -multiple rule, value parsekey uniqueness/non-dash rules). Internal: dead values-loop identifiers removed (val_ident/val_ident_is_parsekey/values-side seen_pks - assigned per word, never read since the api_valname keying landed), the per-parse parsekey reverse map now iterates only VAL_NAMES (leader map removed entirely - a leader -parsekey is rejected at define time), a dead parsekey-from--default derivation removed in resolve, stale pre-G-084 comments corrected. parsekey.test: parsekey_shared_key_multiple_rejected strengthened (message pins, {0 1}-allowed/{0 4}-rejected range coverage, the -parsekeymode error escape hatch incl collection and optionconflict), new parsekey_value_parsekey_integrity; parsereport.test parsereport_storagekey_attribution + parsestatus.test parsestatus_storagekey_attribution pin the attribution (incl the shared-group default/default remaining-gap row). Full punk/args suite 401/0. #0.24.0 - G-084 increment 2 (parsekey completeness, part 2 - the settled cross-member design + integrity closes): -multiple on a member of a shared-parsekey OPTION group is now a DEFINE-TIME error unless ALL members belong to one @opts group marked -parsekeymode error (whose G-083 mutual exclusivity makes per-member collection well-defined; a single -multiple member with its own parsekey is unrestricted). This settles the G-084 cross-member accumulation design decision as rejection rather than accumulate-in-received-order: cross-member collection on one storage key is ambiguous (collect-vs-replace undefined; one member's collected list would silently displace another's). The check runs AFTER the G-053 -multiple canonicalisation so range forms get correct verdicts: {0 1} is replace-shape and allowed on a shared group, collect shapes (boolean 1, max>1, unbounded) are rejected. Value -parsekey integrity settled at define time: duplicate value parsekeys are rejected (trailing values consume positionally so distinct values sharing one result slot silently overwrote each other), and a dash-led value parsekey is rejected (it landed in the options namespace of the result/received dicts and ABORTED parse with an internal error in the option-shaped validation paths). parse_status/parse_report storage-key attribution: parse_status_build now resolves a storage key claimed by exactly ONE argument (a value's -parsekey, an option's declared -parsekey or its '='-suffixed trimmed tail) back to that argument, so argstatus/Source report received with the value-in-effect instead of default/0; shared-parsekey group members stay unresolved (per-member attribution does not survive the storage fold - the remaining recorded G-084 display gap, needs engine-level per-member tracking in the parse result; parse_report's display bridge still shows the group value on each member row). undefine/undefine_deflist now return "" (previously returned the final 'dict unset' expression - the WHOLE cache dict, hundreds of KB, to any caller capturing the value). define -help now documents -parsekey (storage-key rename for options and values, leader rejection, shared groups and -parsekeymode interaction, last-defined-member-wins defaults precedence, the -multiple rule, value parsekey uniqueness/non-dash rules). Internal: dead values-loop identifiers removed (val_ident/val_ident_is_parsekey/values-side seen_pks - assigned per word, never read since the api_valname keying landed), the per-parse parsekey reverse map now iterates only VAL_NAMES (leader map removed entirely - a leader -parsekey is rejected at define time), a dead parsekey-from--default derivation removed in resolve, stale pre-G-084 comments corrected. parsekey.test: parsekey_shared_key_multiple_rejected strengthened (message pins, {0 1}-allowed/{0 4}-rejected range coverage, the -parsekeymode error escape hatch incl collection and optionconflict), new parsekey_value_parsekey_integrity; parsereport.test parsereport_storagekey_attribution + parsestatus.test parsestatus_storagekey_attribution pin the attribution (incl the shared-group default/default remaining-gap row). Full punk/args suite 401/0.
#0.23.0 - G-084 increment 1 (parsekey completeness, part 1): value -parsekey is now supported (was: accepted at define time but aborted parse). A value record declaring -parsekey now renames its result `values` slot and its `received` key to the parsekey (e.g `@values {v1 -parsekey renamed -type string}` parses `hello` to `values={renamed hello}`, `received` keyed by `renamed`), and -multiple value members collect under the parsekey (e.g the `variable` setvalues form `?name value...?` pairs collect under `name_value`). The fix keys VAL_DEFAULTS and the values_dict init/storage by parsekey (matching the already-parsekey-keyed VAL_REQUIRED), tracks the parsekey in a new api_valnames_received list (the internal valnames_received stays argname-keyed for the -multiple first/continuation gate and G-053 occurrence enforcement), uses api_valnames_received for the required-satisfaction check and the received dict, and adds a per-form val_pk2name/leader_pk2name reverse map so the valmin clause-length fallback and the post-parse validation loop can resolve a parsekey back to its argname for ARG_INFO/arg_checks lookups. -parsekey on a leader is now REJECTED at define time with a clear message (was: silently ignored) - no live caller uses a leader -parsekey, so the goal's no-silent-ignore contract is met by rejection rather than parallel hot-path surgery; the dead req_name derivation in the leaders resolve branch is removed. The @values directive line still rejects -parsekey (a group-default parsekey is not a feature; per-arg value -parsekey is). The defaulted-members precedence rule for a shared-parsekey group (none received) is now documented as last-defined-member-wins (deterministic in definition order) and the in-code `? review` is removed. The tclcore moduledoc `#todo - fix -parsekey for leaders and values` is resolved (value supported, leader rejected). parsekey.test: the value GAP flipped to parsekey_value_result_key_settled, the leader GAP flipped to parsekey_leader_parsekey_rejected, the @values-line GAP split to parsekey_values_line_rejects_parsekey; the defaults GAP flipped to a settled pin. dev/parsekey-knownbugs.test: the value and leader disabled pins retired (settled in parsekey.test); the cross-member -multiple accumulation pin remains (increment 3). Full punk/args suite 399/0 (1 skipped), punk/ns 125/125, broader punk sweep 989/0. #0.23.0 - G-084 increment 1 (parsekey completeness, part 1): value -parsekey is now supported (was: accepted at define time but aborted parse). A value record declaring -parsekey now renames its result `values` slot and its `received` key to the parsekey (e.g `@values {v1 -parsekey renamed -type string}` parses `hello` to `values={renamed hello}`, `received` keyed by `renamed`), and -multiple value members collect under the parsekey (e.g the `variable` setvalues form `?name value...?` pairs collect under `name_value`). The fix keys VAL_DEFAULTS and the values_dict init/storage by parsekey (matching the already-parsekey-keyed VAL_REQUIRED), tracks the parsekey in a new api_valnames_received list (the internal valnames_received stays argname-keyed for the -multiple first/continuation gate and G-053 occurrence enforcement), uses api_valnames_received for the required-satisfaction check and the received dict, and adds a per-form val_pk2name/leader_pk2name reverse map so the valmin clause-length fallback and the post-parse validation loop can resolve a parsekey back to its argname for ARG_INFO/arg_checks lookups. -parsekey on a leader is now REJECTED at define time with a clear message (was: silently ignored) - no live caller uses a leader -parsekey, so the goal's no-silent-ignore contract is met by rejection rather than parallel hot-path surgery; the dead req_name derivation in the leaders resolve branch is removed. The @values directive line still rejects -parsekey (a group-default parsekey is not a feature; per-arg value -parsekey is). The defaulted-members precedence rule for a shared-parsekey group (none received) is now documented as last-defined-member-wins (deterministic in definition order) and the in-code `? review` is removed. The tclcore moduledoc `#todo - fix -parsekey for leaders and values` is resolved (value supported, leader rejected). parsekey.test: the value GAP flipped to parsekey_value_result_key_settled, the leader GAP flipped to parsekey_leader_parsekey_rejected, the @values-line GAP split to parsekey_values_line_rejects_parsekey; the defaults GAP flipped to a settled pin. dev/parsekey-knownbugs.test: the value and leader disabled pins retired (settled in parsekey.test); the cross-member -multiple accumulation pin remains (increment 3). Full punk/args suite 399/0 (1 skipped), punk/ns 125/125, broader punk sweep 989/0.
#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.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.

2
src/tests/modules/AGENTS.md

File diff suppressed because one or more lines are too long

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

@ -351,5 +351,84 @@ namespace eval ::testspace {
set result set result
} -result [list 1] } -result [list 1]
#--------------------------------------------------------------------
# allocation participation (directed work 2026-08-08, post-G-053)
#--------------------------------------------------------------------
#added 2026-08-08 (agent) - directed work: bounded -multiple occurrence ranges
#now PARTICIPATE in positional allocation instead of being post-loop
#enforcement only. Previously greedy collection overran a bounded max unless a
#type screen happened to stop it (untyped {3 3} followed by a -multiple tail
#collected 4 and then occurrencecount reported the overrun allocation itself
#created), and a later REQUIRED ranged argument was starved to a single
#reserved clause. Three cooperating sites: the get_dict_can_assign_value cap
#(an argument at its bounded max yields, serving both the leaders and values
#loops), min-occurrence-aware reservations (the allocator's tail_needs and the
#derived valmin floor - an explicit '@values -min' still overrides), and the
#leader-scan cap for a bounded -multiple last leader.
test multirange_alloc_cap_untyped_value {untyped {3 3} value takes exactly 3 then yields to a following -multiple tail}\
-setup $common -body {
set argd [punk::args::parse {a 1 2 3 e f} withdef @values v1 {nums -multiple {3 3}} {tail -multiple 1}]
lappend result [dict get $argd values]
set result
} -result [list {v1 a nums {1 2 3} tail {e f}}]
test multirange_alloc_cap_beats_type_screen {typed {3 3} value with four consecutive ints stops at 3 (cap, not the int screen)}\
-setup $common -body {
set argd [punk::args::parse {a 1 2 3 4 e} withdef @values v1 {nums -multiple {3 3} -type int} {tail -multiple 1}]
lappend result [dict get $argd values]
set result
} -result [list {v1 a nums {1 2 3} tail {4 e}}]
test multirange_alloc_cap_bounded_variable {bounded-variable {1 2} takes up to its max (greedy-up-to-max) then yields}\
-setup $common -body {
set argd [punk::args::parse {1 2 3 4} withdef @values {nums -multiple {1 2}} {tail -multiple 1}]
lappend result [dict get $argd values]
set result
} -result [list {nums {1 2} tail {3 4}}]
test multirange_alloc_reservation_required_range {a greedy earlier -multiple cannot starve a later required {3 3} (min-occurrence reservation)}\
-setup $common -body {
set argd [punk::args::parse {x y 1 2 3} withdef @values {head -multiple 1} {nums -multiple {3 3}}]
lappend result [dict get $argd values]
#clause-size 2 with {2 2}: reserves 2 occurrences x 2 words
set argd [punk::args::parse {x a 1 b 2} withdef @values {head -multiple 1} {pairs -multiple {2 2} -type {string int}}]
lappend result [dict get $argd values]
set result
} -result [list\
{head {x y} nums {1 2 3}}\
{head x pairs {{a 1} {b 2}}}\
]
test multirange_alloc_leaders_side {leaders side: a bounded {3 3} last leader caps the leader scan and its loop; leader/value split reserves for a required ranged value}\
-setup $common -body {
#bounded -multiple leader takes exactly 3, remaining words flow to values
set argd [punk::args::parse {1 2 3 e f} withdef {@leaders -min 0 -max -1} {nums -multiple {3 3}} @values {tail -multiple 1}]
lappend result [list [dict get $argd leaders] [dict get $argd values]]
#greedy -multiple leader yields enough words for a required {3 3} value (valmin floor)
set argd [punk::args::parse {x y 1 2 3} withdef {@leaders -min 0 -max -1} {head -multiple 1} @values {nums -multiple {3 3}}]
lappend result [list [dict get $argd leaders] [dict get $argd values]]
set result
} -result [list\
{{nums {1 2 3}} {tail {e f}}}\
{{head {x y}} {nums {1 2 3}}}\
]
test multirange_alloc_oversupply_pointed {genuine over-supply reports the pointed occurrence limit at the overflow site (G-082 rejection kind occurrence)}\
-setup $common -body {
set err [catch {punk::args::parse {1 2 3 4} withdef @values {nums -multiple {3 3}}} msg eopts]
lappend result [list $err [lindex [dict get $eopts -errorcode] 2] [string match "*accepts at most 3 occurrence(s)*" $msg]]
set result
} -result [list\
{1 {occurrencecount nums count 4 max 3} 1}\
]
test multirange_alloc_legacy_greed_unchanged {legacy boolean -multiple greed is unchanged (all words minus single-clause reservation)}\
-setup $common -body {
set argd [punk::args::parse {a b c d} withdef @values {head -multiple 1} {tail -type string}]
lappend result [dict get $argd values]
set result
} -result [list {head {a b c} tail d}]
} }
tcltest::cleanupTests tcltest::cleanupTests
Loading…
Cancel
Save