diff --git a/goals/G-083-punkargs-argument-relations.md b/goals/G-083-punkargs-argument-relations.md index 2ab771e7..f59c9c9e 100644 --- a/goals/G-083-punkargs-argument-relations.md +++ b/goals/G-083-punkargs-argument-relations.md @@ -108,7 +108,26 @@ Design leans (settle and record here during the work): clicks uses `-parsekeymode error` on its single named group (full pairwise exclusivity). Both adoption cases use named groups. Unnamed shared-parsekey groups default to override (a user wanting strict adds `-group`). - - Remains for acceptance: increment 2 (parse-time optionconflict raise in the - shared post-resolution block, mash+ordinary parity, parse_status_classify); - increment 3 (synopsis/usage hints for conflicts/parsekeymode; lsearch + clock - clicks moduledoc adoption and dropping the caveat). + - Remains for acceptance: increment 3 (synopsis/usage hints for conflicts/parsekeymode; + lsearch + clock clicks moduledoc adoption and dropping the caveat). +- 2026-08-07 increment 2 (parse-time enforcement, punk::args 0.20.0): the optionconflict + failure class joins PUNKARGS VALIDATION, mirroring optionmissing's shape + {optionconflict received }. Enforced 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 (verified: -ab mash with -conflicts and with -parsekeymode error + both raise). Checked against RECEIVED args only (defaults never conflict, pinned), + 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, + not end-of-input exhaustion, so parse_status_classify maps optionconflict to invalid + (not incomplete) and the candidacy/viability probe reports it. Define-time check + added: a -parsekeymode error group must also declare a non-empty -parsekey. + relations.test extended with 7 enforcement pins (raise cases for -conflicts and + -parsekeymode error, defaults-never-conflict, cross-group conflicts, one-received-ok, + override-legacy last-wins, parse_status invalid). Legacy untouched by default + confirmed: full punk/args suite 363 total / 360 passed / 3 skipped / 0 failed via + tclsh90s; punk/ns suite 125/125 clean. + - Bug found and fixed during increment 2: the group-co-occurrence dedup check + initially used `ni` (not-in) where `in` was meant - `ni` returns true on an empty + list, so it skipped every member and the check never fired. Corrected to `in`. diff --git a/src/modules/punk/args-999999.0a1.0.tm b/src/modules/punk/args-999999.0a1.0.tm index a8ec83bc..f24d9927 100644 --- a/src/modules/punk/args-999999.0a1.0.tm +++ b/src/modules/punk/args-999999.0a1.0.tm @@ -3814,6 +3814,20 @@ tcl::namespace::eval punk::args { } unset -nocomplain _g83_argnames _g83_resolve_targets _g83_an _g83_pk _g83_other _g83_other_pk _g83_t _g83_tpk _g83_conflicts _g83_target + #(c) a group marked -parsekeymode error must have a non-empty -parsekey - + #the strict mode only has meaning for a shared-parsekey group (distinct + #members collide on the shared result key); an unnamed or keyless group + #has no shared key to conflict on. + dict for {_g83_g _g83_ginfo} [dict get $FDICT OPT_GROUPS] { + if {[punk::args::system::Dict_getdef $_g83_ginfo -parsekeymode override] ne "error"} continue + if {$_g83_g eq ""} continue ;#-parsekeymode required -group at the @opts line already + set _g83_gpk [punk::args::system::Dict_getdef $_g83_ginfo -parsekey ""] + if {$_g83_gpk eq ""} { + error "punk::args::resolve - -parsekeymode error for group '$_g83_g' requires the group to also declare a non-empty -parsekey (the strict mode only applies to a shared-parsekey group). @id:$DEF_definition_id" + } + } + unset -nocomplain _g83_g _g83_ginfo _g83_gpk + #set mashargs [dict get $F $fid OPT_MASHES] set mashargs [dict get $FDICT OPT_MASHES] if {[llength $mashargs]} { @@ -7167,6 +7181,12 @@ tcl::namespace::eval punk::args { missingrequiredleader - missingrequiredvalue - leadermissing - optionmissing - valuemissing - missingoptionvalue { return incomplete } + optionconflict { + #G-083: two received arguments marked as conflicting (per-arg -conflicts, or + #distinct members of a -parsekeymode error group) - a hard contradiction, + #not end-of-input exhaustion: appending words cannot resolve it. + return invalid + } leadingvaluecount - trailingvaluecount { #payload: min max set num [lindex $payload 0] @@ -10455,6 +10475,7 @@ tcl::namespace::eval punk::args { } #puts "-arg_info->$arg_info" set flagsreceived [list] ;#for checking if required flags satisfied + set optsets_received [list] ;#G-083: per-optset received tracker (options only) for -conflicts/-parsekeymode checks - flagsreceived collapses shared-parsekey members onto one api_opt, so the optset is tracked separately here set solosreceived [list] set multisreceived [list] #secondary purpose: @@ -10503,7 +10524,7 @@ tcl::namespace::eval punk::args { set OPT_ALL_MASH_LETTERS [dict get $formdict OPT_ALL_MASH_LETTERS] set OPTSPEC_DEFAULTS [dict get $formdict OPTSPEC_DEFAULTS] set OPT_CHECKS_DEFAULTS [dict get $formdict OPT_CHECKS_DEFAULTS] - #set OPT_GROUPS [dict get $formdict OPT_GROUPS] + set OPT_GROUPS [dict get $formdict OPT_GROUPS] ;#G-083: -parsekeymode per-group strict mode set VAL_DEFAULTS [dict get $formdict VAL_DEFAULTS] set VAL_REQUIRED [dict get $formdict VAL_REQUIRED] @@ -11178,6 +11199,7 @@ tcl::namespace::eval punk::args { } } lappend flagsreceived $api_opt + lappend optsets_received $mashflagoptionset incr posn } #update vals_remaining_possible by one or 2 if the last flag took a value. @@ -11418,6 +11440,7 @@ tcl::namespace::eval punk::args { lappend solosreceived $api_opt ;#dups ok } lappend flagsreceived $api_opt ;#dups ok + lappend optsets_received $optionset ;#G-083 } else { #starts with - but unmatched option flag #comparison to valmin already done above @@ -12287,6 +12310,78 @@ tcl::namespace::eval punk::args { } } + #--------------------------------------------------------------------------------------------- + #G-083: optionconflict - per-arg -conflicts and @opts -parsekeymode error group exclusivity. + #Checked against RECEIVED arguments only (defaults never conflict), after prefix/ + #abbreviation resolution, on the OPTSET identity (not the received-name form - flagsreceived + #collapses distinct shared-parsekey members onto one api_opt, so the matched optset is + #tracked in optsets_received for options). Runs unconditionally: a received conflict is a + #hard contradiction in any mode (candidacy probe or live parse), not end-of-input + #exhaustion, so it is not gated by the skip_* flags above. Mirrors the optionmissing + #errorcode shape: PUNKARGS VALIDATION {optionconflict received }. + set _g83_received_optsets $optsets_received + foreach _g83_n $leadernames_received {if {[dict exists $ARG_INFO $_g83_n]} {lappend _g83_received_optsets $_g83_n}} + foreach _g83_n $valnames_received {if {[dict exists $ARG_INFO $_g83_n]} {lappend _g83_received_optsets $_g83_n}} + #effective parsekey of an optset: its own -parsekey, else the group's -parsekey if it + #is in a named group that declares one, else "" (the member then keys on its api_opt). + set _g83_effpk [dict create] + set _g83_pk_to_optsets [dict create] + foreach _g83_optset $OPT_NAMES { + set _g83_p [punk::args::system::Dict_getdef [dict get $ARG_INFO $_g83_optset] -parsekey ""] + if {$_g83_p eq ""} { + set _g83_grp [punk::args::system::Dict_getdef [dict get $ARG_INFO $_g83_optset] -group ""] + if {$_g83_grp ne "" && [dict exists $OPT_GROUPS $_g83_grp]} { + set _g83_p [punk::args::system::Dict_getdef [dict get $OPT_GROUPS $_g83_grp] -parsekey ""] + } + } + dict set _g83_effpk $_g83_optset $_g83_p + if {$_g83_p ne ""} {tcl::dict::lappend _g83_pk_to_optsets $_g83_p $_g83_optset} + } + #G-083 conflict check: per-arg -conflicts (iterate received optsets in definition order + #for deterministic output; report the first conflicting pair). + foreach _g83_optset $OPT_NAMES { + if {$_g83_optset ni $_g83_received_optsets} continue + set _g83_conflicts [punk::args::system::Dict_getdef [dict get $ARG_INFO $_g83_optset] -conflicts {}] + if {![llength $_g83_conflicts]} continue + foreach _g83_t $_g83_conflicts { + #resolve target to optset(s): flagname spelling, then argname, then parsekey + if {[dict exists $lookup_optset $_g83_t]} { + set _g83_t_optsets [list [dict get $lookup_optset $_g83_t]] + } elseif {[dict exists $ARG_INFO $_g83_t]} { + set _g83_t_optsets [list $_g83_t] + } elseif {[dict exists $_g83_pk_to_optsets $_g83_t]} { + set _g83_t_optsets [dict get $_g83_pk_to_optsets $_g83_t] + } else { + set _g83_t_optsets [list] ;#define-time should have rejected it - defensive skip + } + foreach _g83_t_optset $_g83_t_optsets { + if {$_g83_t_optset eq $_g83_optset} continue ;#not a conflict with itself + if {$_g83_t_optset ni $_g83_received_optsets} continue ;#target not received + set msg "Arguments '$_g83_optset' and '$_g83_t_optset' for %caller% are marked as conflicting (-conflicts) and may not be supplied together." + return -options [list -code error -errorcode [list PUNKARGS VALIDATION [list optionconflict $_g83_optset $_g83_t_optset received [list {*}$leadernames_received {*}$flagsreceived {*}$valnames_received]] -argspecs $argspecs]] $msg + } + } + } + #G-083 group exclusivity: @opts -parsekeymode error - distinct members of a shared- + #parsekey group received together is an error (iterate groups for deterministic output). + dict for {_g83_g _g83_ginfo} $OPT_GROUPS { + if {[punk::args::system::Dict_getdef $_g83_ginfo -parsekeymode override] ne "error"} continue + set _g83_g_received [list] + foreach _g83_optset $OPT_NAMES { + if {$_g83_optset ni $_g83_received_optsets} continue + set _g83_grp [punk::args::system::Dict_getdef [dict get $ARG_INFO $_g83_optset] -group ""] + if {$_g83_grp ne $_g83_g} continue + if {$_g83_optset in $_g83_g_received} continue + lappend _g83_g_received $_g83_optset + } + if {[llength $_g83_g_received] > 1} { + set _g83_a [lindex $_g83_g_received 0] + set _g83_b [lindex $_g83_g_received 1] + set msg "Arguments '$_g83_a' and '$_g83_b' for %caller% belong to mutually exclusive group '$_g83_g' (-parsekeymode error) and may not be supplied together." + return -options [list -code error -errorcode [list PUNKARGS VALIDATION [list optionconflict $_g83_a $_g83_b received [list {*}$leadernames_received {*}$flagsreceived {*}$valnames_received]] -argspecs $argspecs]] $msg + } + } + unset -nocomplain _g83_received_optsets _g83_n _g83_effpk _g83_pk_to_optsets _g83_optset _g83_p _g83_grp _g83_conflicts _g83_t _g83_t_optsets _g83_t_optset _g83_g _g83_ginfo _g83_g_received _g83_a _g83_b #--------------------------------------------------------------------------------------------- #maintain order of opts $opts values $values as caller may use lassign. set receivednames [list {*}$leadernames_received {*}$flagsreceived {*}$valnames_received] diff --git a/src/modules/punk/args-buildversion.txt b/src/modules/punk/args-buildversion.txt index 4a9b79aa..20d96337 100644 --- a/src/modules/punk/args-buildversion.txt +++ b/src/modules/punk/args-buildversion.txt @@ -1,6 +1,7 @@ -0.19.0 +0.20.0 #First line must be a semantic version number #all other lines are ignored. +#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 received } - 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 (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. #0.18.0 - G-151 annotated success render: new punk::args::parse_report - landing report for a SUCCESSFUL parse, one row per value-holding argument (received, or filled from -default) in declaration-section order (leaders, opts, values) with columns Argument | Source | Value - the success-side sibling of the arg_error usage table (a flag-like word consumed as a VALUE by position shows attributed to its consuming argument - the make.tcl dry-run 'kitname = punk91 -confirm 0' class). Input: a punk::args::parse result dict (its id key must resolve to a registered definition), or words + 'withid ' (the parse is performed first; a rejected line raises the same validation error parse would - only successful parses have a landing report). Source column: received / received xN (-multiple aggregation count) / default - the plain-mode-safe twin of the colour marking (received CLR(check), default CLR(parsekey_hint)); absent optionals with no default-in-effect get no row. Returns: table (arg_error-styled bordered block via textblock; the default), tableobject, string (plain lines 'name (source) = value'; also the degraded no-textblock path), dict (machine form: id/form/rows with class/source/received/positions/multiple/hasvalue/value - never elided). Display elision is type-aware, deterministic and always marked (display-hint honesty): strings single-line-ized (ansistring VIEW -lf 1 -vt 1 -sp 0 - embedded controls/ANSI render visibly so a value cannot disturb the table; real spaces stay spaces) then grapheme-capped via the new ansistring TRUNCATE (punk::ansi 0.2.0) with trailing '...' plus ' (len N)' citing the raw value's string length; -multiple aggregations and -type list values element-capped with '(+N more)' (whole list-quoted elements kept while they fit, first element char-capped if it alone overflows); -type dict values pair-capped with '(+N pairs)'; markers stay intact so cells can exceed very small caps by the marker width; plain-string fallbacks when punk::ansi is unavailable. Width cascade per the G-149 plumbing precedent: caller -valuewidth > new @cmd directive key -reportvaluewidth > built-in 48; 0 means no cap; deliberately never console-derived (deterministic and pipe-safe - the G-007 emit-then-query lesson). Value-in-effect lookup bridges storage-key folds at display level (-parsekey renames, aliased optionsets' last-|-member finalopts fold) so received aliased/parsekey'd arguments keep their row - G-084 owns the storage-key model itself. Failure-side rendering (arg_error/usage) unchanged. make.tcl retiring its interim one-line 'dry-run: line accepted ...' report in favour of this render is consumer follow-through after a bootsupport promotion (not part of this change). New testsuite parsereport.test; define -help documents @cmd -reportvaluewidth beside the other directive-options. #0.17.0 - G-149 configurable multi-form synopsis hint labels: the per-form hint labels rendered beside a multi-form definition's synopsis lines in the usage/arg_error table (hard-coded ' i -form N ...' previously) are now a template substituted per form - %formindex% replaced with the form's 0-based ordinal, %formname% with its name; an empty template suppresses the labels entirely (the cell keeps just the Synopsis: label, as single-form renders do). Precedence: caller option > definition > built-in default. New arg_error/usage option -formhint