From 2aad4d17772a75ea6898c13e69f1862187518224 Mon Sep 17 00:00:00 2001 From: Julian Noble Date: Fri, 7 Aug 2026 16:46:58 +1000 Subject: [PATCH] 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 count min | max ) fires in a single post-loop pass per section (opts/leaders/values) via a private::multiple_range_enforce helper. Over-max is a hard contradiction (fires in 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 --- CHANGELOG.md | 20 + GOALS-archive.md | 4 + GOALS.md | 3 - goals/G-053-punkargs-multiple-ranges.md | 75 ---- goals/G-072-punkargs-compound-clause-types.md | 3 +- goals/G-084-punkargs-parsekey-completeness.md | 3 +- .../archive/G-053-punkargs-multiple-ranges.md | 128 +++++++ punkproject.toml | 2 +- src/modules/punk/args-999999.0a1.0.tm | 163 +++++++- src/modules/punk/args-buildversion.txt | 3 +- src/tests/modules/AGENTS.md | 2 +- .../args/testsuites/args/multipleranges.test | 355 ++++++++++++++++++ 12 files changed, 675 insertions(+), 86 deletions(-) delete mode 100644 goals/G-053-punkargs-multiple-ranges.md create mode 100644 goals/archive/G-053-punkargs-multiple-ranges.md create mode 100644 src/tests/modules/punk/args/testsuites/args/multipleranges.test diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c458ae..e3db3a6f 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/GOALS-archive.md b/GOALS-archive.md index a4b186f0..d25beb8f 100644 --- a/GOALS-archive.md +++ b/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. diff --git a/GOALS.md b/GOALS.md index 96e03a5d..01e4bb89 100644 --- a/GOALS.md +++ b/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) diff --git a/goals/G-053-punkargs-multiple-ranges.md b/goals/G-053-punkargs-multiple-ranges.md deleted file mode 100644 index 9c36adf1..00000000 --- a/goals/G-053-punkargs-multiple-ranges.md +++ /dev/null @@ -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. diff --git a/goals/G-072-punkargs-compound-clause-types.md b/goals/G-072-punkargs-compound-clause-types.md index ecf646ec..4f412992 100644 --- a/goals/G-072-punkargs-compound-clause-types.md +++ b/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. diff --git a/goals/G-084-punkargs-parsekey-completeness.md b/goals/G-084-punkargs-parsekey-completeness.md index 0e645422..8351ac31 100644 --- a/goals/G-084-punkargs-parsekey-completeness.md +++ b/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 diff --git a/goals/archive/G-053-punkargs-multiple-ranges.md b/goals/archive/G-053-punkargs-multiple-ranges.md new file mode 100644 index 00000000..b9edd7f5 --- /dev/null +++ b/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 count min | max ) + fires in a single post-loop pass per section (opts/leaders/values) via a + private::multiple_range_enforce helper; over-max is a hard contradiction + (fires in normal and viability-probe modes, parse_status_classify maps it to + invalid), under-min is pure end-of-input exhaustion (SUPPRESSED in the G-152 + viability probe via the viabilitycheck arg, classified incomplete so a viable + form reports incomplete not invalid). The usage-table Multi column reflects + the range (0-1 / 2-4 / 1+; greencheck stays for legacy 1), the string renderer + emits MULTI:0-1 etc., and the synopsis distinguishes at-most-once (?arg?, no + ellipsis) from repeating (arg...). -multipleunique/-multipleuniqueset compose + unchanged. define -help documents the range form and the -optional/range-min + rule. New testsuite multipleranges.test (28 tests). Legacy untouched by + default confirmed: full punk/args suite 399/0 (3 skipped), punk/ns 125/125. + +## Notes + +- `-multipleunique` / `-multipleuniqueset` remain the uniqueness knobs and only + make sense for max > 1; they compose with ranges unchanged. +- Related: G-045 (authoring ergonomics, achieved 2026-07-12 - see + goals/archive/G-045-punkargs-authoring-ergonomics.md), G-046 (parse-time + performance - the canonicalization must not regress the hot path). +- The runtests `-include-paths` fix (repeatable, accumulate, single-list form still + accepted) shipped independently on 2026-07-10 and does not depend on this goal. +- Archived-goal references in this file: G-046 achieved 2026-07-10 (goals/archive/G-046-punkargs-deferred-help-and-fixes.md). +- Referenced by G-083 (argument relations - "folding into G-053" + considered and rejected there; achieved - see + goals/archive/G-083-punkargs-argument-relations.md) and G-084 (-parsekey + cross-member -multiple collection rides this goal's occurrence arity) - + recorded 2026-07-24 after overlap review. + +## Follow-ons + +Follow-on: G-084 cross-member -multiple collection on a shared parsekey rides this goal's occurrence-arity model - decide accumulate-in-received-order vs error-on-cross-member-combination now that the {min max} vocabulary is landed (see goals/G-084-punkargs-parsekey-completeness.md) => goal G-084 diff --git a/punkproject.toml b/punkproject.toml index 61223793..7bf1f6b3 100644 --- a/punkproject.toml +++ b/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 - diff --git a/src/modules/punk/args-999999.0a1.0.tm b/src/modules/punk/args-999999.0a1.0.tm index f05bd419..f30a2da2 100644 --- a/src/modules/punk/args-999999.0a1.0.tm +++ b/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 is valid and equivalent to -a -b -c -f but -afc is not valid - -multiple (for leaders & values defines whether + -multiple (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 (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 count min | max . 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: min 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 diff --git a/src/modules/punk/args-buildversion.txt b/src/modules/punk/args-buildversion.txt index dd9d5beb..1de9ff59 100644 --- a/src/modules/punk/args-buildversion.txt +++ b/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 count min | max ) 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: ' 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 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. diff --git a/src/tests/modules/AGENTS.md b/src/tests/modules/AGENTS.md index bcdfab48..080881e4 100644 --- a/src/tests/modules/AGENTS.md +++ b/src/tests/modules/AGENTS.md @@ -43,7 +43,7 @@ Unit tests for editable source modules under `src/modules/`, `src/modules_tcl8/` - `commandstack/` — commandstack (cooperative command renaming) tests (`testsuites/commandstack/commandstack.test`, 2026-08-03 - characterisation suite + the G-160 hygiene-pass contract at commandstack 0.6.0 + the 0.7.0 convenience removal forms + the 0.7.1 reload-contract state guards (G-160 follow-ons, 2026-08-04); usage-driven from punk::packagepreference/packagetrace/packagesuppress/punk-auto_execok/punk::nav::fs-cd): record shape as a contract (token first/renamer second dict key order for the lsearch -index 1/-index 3 convention, trailing `did_rename` 0|1, `{implementation {} did_rename 0}` no-rename signal), COMMANDSTACKNEXT/COMMANDSTACKNEXT_ORIGINAL delegation + the `commandstack::next` helper (caller-context parity with the manual uplevel convention pinned), unique+monotonic per-(renamer,command) tokenids (same-renamer re-renames chain and are removable by exact token, third rename succeeds), multi-renamer stacking with removal in any order (bottom-removal re-linking), builtin renames (next_implementor `original`), remove_rename's three argument forms + unknown-renamer errors, the 0.7.0 convenience removal forms (pop_rename - command form pops topmost-for-renamer and returns the removed record, bare form searches live stacks with a multi-command ambiguity error; remove_renamer - all of a renamer's entries across live stacks with correct re-linking, Rename_stack-parked stacks skipped, removed records returned keyed by command; restore_original - whole-stack unwind to the original returning the record count, deliberately registering stack-evidenced renamers so it survives a known_renamers reset while the renamer-explicit forms keep the gate; all silent with debug off), the token->implementation map get_next_command resolves through (map/stack consistency pinned across rename/remove; parked stacks keep dispatching), channel discipline (silent full cycle with debug off; warnings only under debug), debug argument validation, -renamer misplacement errors, get_stack raw-key-first retrieval of Rename_stack-parked records + Rename_stack 1/0 returns, Delete_stack live-record guard (errors; empty/missing return 1), get_IMPLEMENTOR classification incl builtin-where-cmdtype-exists (dynamic expectation - undetermined on 8.6), lib::split_body round-trip, lib::splitx, show_stack fallback render, the reload contract (a module re-source preserves ALL state - stacks/token counters/token map/known_renamers/debug each info-exists guarded; delegation and exact-token removal keep working after reload), the help overview, and lazy punk::args registration of the PUNKARGS docs. Behavioural tests run in fresh child interps per test (module sourced by path relative to the test file; a ::puts shim captures module output for silence/warning assertions and keeps runner output clean); descriptions are single-line per the tcltestrun banner-parsing style guidance in src/tests/AGENTS.md (a hard contract until G-161 made the parser multi-line tolerant). Green on tclsh90 (9.0.3) and punk86 (8.6) - `punkcheck/` — punkcheck module tests (install, summarize_install_resultdict, installtrack) - `punk/ansi/` — punk::ansi tests (`testsuites/ansi/`): ansistrip/ansimerge, plus characterization of the ANSI-at-position mechanisms (`ansistring.test`: INDEX/INDEXCODE/INDEXCHAR/RANGE/INSERT grapheme indexing with SGR-prefix merging, INDEXCOLUMNS/COLUMNINDEX double-wide column mapping, trim/VIEW; extended 2026-08-05 by the G-151 pre-modification coverage survey - VIEW -lf 0/1/2 single-line-ization modes + always-on NUL + silent unknown-option tolerance (load-bearing for overtype's '-nul 1' sites) + C1/zero-width visuals, RANGE end-relative/clamping plus the merged-code-stack re-emission and trailing-code-drop semantics a truncate primitive must respect, COUNT-vs-length combining-accent divergence, NEW object basics, and the KNOWN-DEFICIENCY pin that VIEWCODES/VIEWSTYLE raise invalid-command in a plain punk::ansi interp (bare 'a+'/'a' interactive-alias dependency; pinned in a fresh child interp); plus the TRUNCATE primitive pins landed with G-151 (punk::ansi 0.2.0) - fits-unchanged byte identity incl styled fixtures, grapheme-exact capping with marker budget participation and marker-alone truncation, SGR-reset-before-marker on styled prefixes, CJK/combining-cluster grapheme safety, and strict option errors (deliberately unlike VIEW's pinned silent tolerance)), code splitting invariants (`ta.test`: detect/detectcode distinction, split_codes/split_codes_single/split_at_codes shapes and round-trip) and single-code/effective-state semantics (`codetype.test`: is_sgr_reset/has_sgr_leadingreset, has_any/all_effective, sgr_merge, sequence_type classify), grepstr characterization (`grepstr.test`: return modes incl summarydict (linemap pinned as always-present - the -help says -n-only, reconciliation deferred to the planned hygiene pass), exact highlight SGR wrapping, -n line numbering, invert + empty-highlight strip, -C context/breaks, capture groups, and the tab deficiency: warns once per call on stderr, single-pass tab line survives - the multi-pass mangling is pinned at consumer level in punk/ns corp.test), and untabify characterization (`untabify.test`: -stops int/list/terminal, -with spaces/unicode/custom-pair, multiline, errors, plus the EXPERIMENTAL -plastic elastic-tabstop mode deliberately pinned-as-interim and retained for possible repl editbuf use). Console queries (get_tabstops/get_size + punk::console::tabwidth) are mocked per the overtype renderline.test pattern - they emit live terminal queries that block/error headless. ANSI codes in these tests are literal escape strings so results are colour-state independent -- `punk/args/` — punk::args tests (`testsuites/args/`): parsing, choices/choicegroups, forms (incl the 2026-08-05 candidacy fencing/fall-through primitive pins ahead of G-168: -regexprefail on optional value slots is form-fatal with no re-landing, unknown-option/dangling-option/option-value-fence failures fall through to sibling forms as successful parses, and the documented per-word limit - a complete option+value+file line double-matches a fenced scriptfile form and a dash-tolerant stdin form), registered-namespace lazy definition loading (`docpackages.test`, G-169 pre-work: inert registration/scan-vs-load split, id_exists never triggers loading, real_id/usage lazy resolution incl tag-prefixed script-level id families, duplicate-definer last-loaded-wins characterization), the G-168 launch-definition model parity (`punkexemodel.test`, punkexe moduledoc 0.7.0, id loading updated for the G-169 handover - the script id resolves via app-punkscript-docs from src/lib (auto_path derived from the test file location) while the moduledoc carries the core-owned ids: script/tclsh selection matrices against the real (script)::punkexe ids with the real-side dispatch/app oracle recorded in goals/archive/G-168, the bare-'-e' viable-incomplete verdict, the ./-e fence message, -encoding fall-through statuses, and the sanctioned complete-'-encoding' multipleformmatches LIMIT pins; the formcheck.test punkexe GAPs flipped to discriminated/sanctioned/selection-sound pins in the same arc), the G-151 landing report (`parsereport.test`, punk::args 0.18.0 parse_report: the canonical flag-like-word-consumed-as-VALUE attribution row, parsed-result vs words+withid entry parity, machine dict shape with declaration-section row order and absent-optional row omission, received xN multiplicity for solos/-multiple opts, type-aware always-marked elision at the default width plus the caller > @cmd -reportvaluewidth > built-in width cascade and never-elided dict returns, VIEW-style single-line-ization of control-bearing values, aliased-optionset storage-key row bridging, bordered-table/tableobject renders, words-form failure errorcode parity with parse, and the registered two-form definition's own render), rendering/indentation characterization, synopsis display characterization (`synopsis.test`: basic italic argname/`` styling, longopt `--x=` alias forms, literal/literalprefix/stringstartswith/stringendswith type-alternates rendering unitalicised, option alternate parenthesization, multi-element clause display incl `?type?` members and argname tail-word hints, `-typesynopsis` value-element lists and option passthrough incl documenter ANSI, and the small-restricted-choice-set literal rule: 1-3 restricted choices render as unitalicised `|`-joined literals in leader/option/value positions with choicegroups counted, >3 or `-choicerestricted 0` falling back to italics, `-typesynopsis` taking precedence), usage-marking characterization (`usagemarking.test`: -parsedargs/-badarg/-parsestatus/-scheme marking primitives plus goodchoice highlighting of selected/default-in-effect choice words, asserted by SGR-parameter subset against the live colour arrays; the G-049 nocolour/colour-leak GAP pins flipped 2026-07-10 to scheme-statelessness assertions), the G-049 parse-status structure (`parsestatus.test`: punk::args::parse_status overall/per-argument statuses, badarg for type/allocation failures, -caller attribution, errorcode -argspecs stripping), -parsekey characterization (`parsekey.test`: result/received/solos/multis keying, shared-key required satisfaction and defaults, mash-path and prefix-abbreviation keying, plus GAP pins for last-defined-member default precedence, cross-member -multiple value loss, and values/leaders parsekey breakage - desired-behaviour pins disabled behind punkargsKnownBug in `testsuites/dev/parsekey-knownbugs.test`; the parsekey/optname collision GAP is flipped (G-083, 2026-08-07: -parsekey colliding with a distinct defined arg's name is now a resolve error unless that arg shares the parsekey), and the G-083 argument-relations define-time vocabulary is pinned in `relations.test` (per-arg -conflicts target validation, @opts -parsekeymode override|error storage and the requires-group/bad-value/requires-parsekey resolve errors), plus its increment-2 parse-time enforcement (optionconflict raise for -conflicts violations and -parsekeymode error co-occurrence, defaults-never-conflict, cross-group conflicts, parse_status invalid classification), and its increment-3 usage/synopsis rendering and lsearch moduledoc adoption (conflicts-with hints appended to per-arg help rows, -parsekeymode error groups annotated 'mutually exclusive' in group headers with override groups unannotated, synopsis one-line form carrying no conflict detail; and the lsearch tclcore moduledoc modelling -sorted/-glob and -bisect/-all incompatibilities via per-arg -conflicts while -glob/-regexp stay last-wins and the 'punk::args fixes required' caveat is dropped))), longopt style-distinction and mash-edge characterisation (`longopts.test`, 2026-08-07 gap-fill: the gnu (`--flag=` inline-only; spaced/solo usage = badoptionformat) vs plain (`--flag` spaced-only; inline `=` = invalidoption) vs mixed (`-f|--file|--file=` both forms incl longopt prefix abbreviation) definition-grammar distinctions, first-`=`-only value splitting and empty inline values, the two `=`-member resolve-time definition errors, single-dash `-flag=value` never split at the `=`, and `@opts -any 1` adhoc passthrough where `--flag=value` splits but `-flag=value` stays a whole adhoc flagname expecting a value; the same gap-fill added to `mashopts.test`: unknown-letter-in-mash invalidoption rejection and the `-any` interplay - defined flags still mash while undefined mash-lookalike tokens are adhoc flags, never mash attempts; error-shape pins use the first three -errorcode elements), and tclcore doc/interpreter behavioural parity (`tclcoreparity.test`, G-054, gated on have_tclcoredocs: 'string is' class choices equal the live-harvested set, per-class docids exist, error-vs-ok agreement across the probe matrix; version-note labels appear when the class is live OR is a declared forward unavailable class - dict labels on every runtime since G-073, unicode keeps the live-only rule; the G-073 forward-class adoption invariants and the USER-SANCTIONED 'di' prefix-strictness exemption (the model is deliberately stricter than real 8.6, full words parity-true); and the G-166 pin that an unavailable class's per-class virtual id LEADS its help with the unavailability statement on runtimes lacking the class while modern runtimes generate it unchanged. All expectations are derived from the running interpreter, green on 8.6/8.7/9.0 through runtests.tcl on each - native Tcl 8.6 has been a supported runner interpreter since 2026-07-21) +- `punk/args/` — punk::args tests (`testsuites/args/`): parsing, choices/choicegroups, forms (incl the 2026-08-05 candidacy fencing/fall-through primitive pins ahead of G-168: -regexprefail on optional value slots is form-fatal with no re-landing, unknown-option/dangling-option/option-value-fence failures fall through to sibling forms as successful parses, and the documented per-word limit - a complete option+value+file line double-matches a fenced scriptfile form and a dash-tolerant stdin form), registered-namespace lazy definition loading (`docpackages.test`, G-169 pre-work: inert registration/scan-vs-load split, id_exists never triggers loading, real_id/usage lazy resolution incl tag-prefixed script-level id families, duplicate-definer last-loaded-wins characterization), the G-168 launch-definition model parity (`punkexemodel.test`, punkexe moduledoc 0.7.0, id loading updated for the G-169 handover - the script id resolves via app-punkscript-docs from src/lib (auto_path derived from the test file location) while the moduledoc carries the core-owned ids: script/tclsh selection matrices against the real (script)::punkexe ids with the real-side dispatch/app oracle recorded in goals/archive/G-168, the bare-'-e' viable-incomplete verdict, the ./-e fence message, -encoding fall-through statuses, and the sanctioned complete-'-encoding' multipleformmatches LIMIT pins; the formcheck.test punkexe GAPs flipped to discriminated/sanctioned/selection-sound pins in the same arc), the G-151 landing report (`parsereport.test`, punk::args 0.18.0 parse_report: the canonical flag-like-word-consumed-as-VALUE attribution row, parsed-result vs words+withid entry parity, machine dict shape with declaration-section row order and absent-optional row omission, received xN multiplicity for solos/-multiple opts, type-aware always-marked elision at the default width plus the caller > @cmd -reportvaluewidth > built-in width cascade and never-elided dict returns, VIEW-style single-line-ization of control-bearing values, aliased-optionset storage-key row bridging, bordered-table/tableobject renders, words-form failure errorcode parity with parse, and the registered two-form definition's own render), rendering/indentation characterization, synopsis display characterization (`synopsis.test`: basic italic argname/`` styling, longopt `--x=` alias forms, literal/literalprefix/stringstartswith/stringendswith type-alternates rendering unitalicised, option alternate parenthesization, multi-element clause display incl `?type?` members and argname tail-word hints, `-typesynopsis` value-element lists and option passthrough incl documenter ANSI, and the small-restricted-choice-set literal rule: 1-3 restricted choices render as unitalicised `|`-joined literals in leader/option/value positions with choicegroups counted, >3 or `-choicerestricted 0` falling back to italics, `-typesynopsis` taking precedence), usage-marking characterization (`usagemarking.test`: -parsedargs/-badarg/-parsestatus/-scheme marking primitives plus goodchoice highlighting of selected/default-in-effect choice words, asserted by SGR-parameter subset against the live colour arrays; the G-049 nocolour/colour-leak GAP pins flipped 2026-07-10 to scheme-statelessness assertions), the G-049 parse-status structure (`parsestatus.test`: punk::args::parse_status overall/per-argument statuses, badarg for type/allocation failures, -caller attribution, errorcode -argspecs stripping), -parsekey characterization (`parsekey.test`: result/received/solos/multis keying, shared-key required satisfaction and defaults, mash-path and prefix-abbreviation keying, plus GAP pins for last-defined-member default precedence, cross-member -multiple value loss, and values/leaders parsekey breakage - desired-behaviour pins disabled behind punkargsKnownBug in `testsuites/dev/parsekey-knownbugs.test`; the parsekey/optname collision GAP is flipped (G-083, 2026-08-07: -parsekey colliding with a distinct defined arg's name is now a resolve error unless that arg shares the parsekey), and the G-083 argument-relations define-time vocabulary is pinned in `relations.test` (per-arg -conflicts target validation, @opts -parsekeymode override|error storage and the requires-group/bad-value/requires-parsekey resolve errors), plus its increment-2 parse-time enforcement (optionconflict raise for -conflicts violations and -parsekeymode error co-occurrence, defaults-never-conflict, cross-group conflicts, parse_status invalid classification), and its increment-3 usage/synopsis rendering and lsearch moduledoc adoption (conflicts-with hints appended to per-arg help rows, -parsekeymode error groups annotated 'mutually exclusive' in group headers with override groups unannotated, synopsis one-line form carrying no conflict detail; and the lsearch tclcore moduledoc modelling -sorted/-glob and -bisect/-all incompatibilities via per-arg -conflicts while -glob/-regexp stay last-wins and the 'punk::args fixes required' caveat is dropped))), the G-053 range-valued -multiple occurrence arity (`multipleranges.test`, punk::args 0.22.0: {min max} range canonicalisation into a per-form MULTIPLE_RANGES companion dict while the stored -multiple boolean keeps its hot-path list-collect meaning so legacy 0/1 stay byte-unchanged, the -optional/range-min reconciliation resolve error, the {0 1}/{2 4}/{1 -1} scalar-vs-list value shapes, parse-time occurrencecount enforcement with min-under-supply suppressed in the G-152 viability probe and classified incomplete (pure exhaustion) while over-max is invalid, the usage-table Multi column showing 0-1/2-4/1+ with the synopsis ?arg? at-most-once ellipsis distinction, and -multipleunique composition), longopt style-distinction and mash-edge characterisation (`longopts.test`, 2026-08-07 gap-fill: the gnu (`--flag=` inline-only; spaced/solo usage = badoptionformat) vs plain (`--flag` spaced-only; inline `=` = invalidoption) vs mixed (`-f|--file|--file=` both forms incl longopt prefix abbreviation) definition-grammar distinctions, first-`=`-only value splitting and empty inline values, the two `=`-member resolve-time definition errors, single-dash `-flag=value` never split at the `=`, and `@opts -any 1` adhoc passthrough where `--flag=value` splits but `-flag=value` stays a whole adhoc flagname expecting a value; the same gap-fill added to `mashopts.test`: unknown-letter-in-mash invalidoption rejection and the `-any` interplay - defined flags still mash while undefined mash-lookalike tokens are adhoc flags, never mash attempts; error-shape pins use the first three -errorcode elements), and tclcore doc/interpreter behavioural parity (`tclcoreparity.test`, G-054, gated on have_tclcoredocs: 'string is' class choices equal the live-harvested set, per-class docids exist, error-vs-ok agreement across the probe matrix; version-note labels appear when the class is live OR is a declared forward unavailable class - dict labels on every runtime since G-073, unicode keeps the live-only rule; the G-073 forward-class adoption invariants and the USER-SANCTIONED 'di' prefix-strictness exemption (the model is deliberately stricter than real 8.6, full words parity-true); and the G-166 pin that an unavailable class's per-class virtual id LEADS its help with the unavailability statement on runtimes lacking the class while modern runtimes generate it unchanged. All expectations are derived from the running interpreter, green on 8.6/8.7/9.0 through runtests.tcl on each - native Tcl 8.6 has been a supported runner interpreter since 2026-07-21) - `punk/nav/ns/` — punk::nav::ns tests (`testsuites/nav/navns.test`): the n/ n// n/// navigation state machine (ns/ transitions absolute/relative/glob-no-nav, failed-nav state preservation, quad-colon normalization, v-form content selection, ensemble annotation) and the ::punk::nav::ns::ns_current variable contract the repl/codethread/subshell seeding all consume; display content is covered in punk/ns nslist.test - `punk/repl/` — punk::repl tests (`testsuites/repl/`): opunk console backend integration (`consolebackends.test`) and repl current-namespace retention (`nscurrent.test`: real codethread via repl::init driven by synchronous runscript sends - inscope evaluation of ns_current, retention across submissions, n/-navigation retained, auto-create-with-notice for missing namespaces, the 2026-07-14 stray-namespace seeding fix pinned behaviourally plus a source-text guard on repl::start's inline template; the end-to-end piped subshell session is covered at shell level by shell/testsuites/punkexe/shellnavns.test - which found the first-subshell shared-code-interp asymmetry and the piped-inscope gap recorded there) - `punk/ns/` — punk::ns tests (`testsuites/ns/`): cmdwhich/cmdinfo/cmd_traverse doc-lookup flow (`cmdflow.test`, G-040 parity; plus the G-166 availability axis on the flowunavail/flowunavail_nodoc fixtures - the `unavailable` cmdinfo key present on every result, exact and unique-prefix landings on a `-choiceunavailable` name resolving that name's virtual docid ATTRIBUTED rather than resolving nothing, the choice traverse attributing without addressing when no virtual docid exists, and cmdtype staying unchanged throughout since availability is a second axis, never a cmdtype value), n/ display machinery characterization (`nslist.test`: tier A get_ns_dicts classification buckets as the machine contract for display reworks - incl package tail/prefix derivation, alias edge cases, usageinfo scan-dependence; tier B per-element layout-agnostic marking - underline/underdouble/underdotted namespace package styles, command type tag colours, exported/imported markers, the punkargs doc icon; tier C REWORK-flagged pins of the current hardcoded 2-col/4-col layout and nspath subtables, to flip deliberately with the planned punk-tables/width-responsive rework; plus the flipped nslist_types_default pin - bare nslist without -types displays all member types since punk::ns 0.7.1 fixed the braced-literal -types default), corp proc-retrieval and syntax/untabify interplay (`corp.test`: name edge cases, -ranges/-n line handling, basic-highlight ansistrip equivalence, -untabify spaces/unicode tab-free output, the KNOWN-DEFICIENCY pin for default -untabify none on tabbed bodies - grepstr warns per pass and brace overlays mangle tabbed lines, deterministic under mocked console tabstops - and a ::tcl::CopyDirectory -untabify spaces smoke test; precursor coverage for the planned punk::ns hygiene pass), cmdtrace characterization (`cmdtrace.test`: -pause 0 non-interactive runs, linedict line-mark keys for flat and 2-word-form nested switches as correct-mark guards, and GAP pins for the upstream nested-switch mismark - core.tcl-lang.org tktview 5d5b1052280c976ea3d4, arm bodies whose split-list index lands on a literal switch-command word report container-relative lines; mark tests gated on have_tclcoredocs because cmdtrace's arm-offset correction parses against the ::switch argdoc; plus the fixed-canary asserting punk::lib::check::has_tclbug_nestedswitch_tracelines still reports the bug - a live behavioural probe, so a fixed Tcl release fails the canary first and triggers the documented flip workflow), cmdhelp usage-rendering integration (`cmdhelp.test`: scheme selection, goodarg/badarg marking incl type/allocation failures, goodchoice highlighting of supplied/default choice words, alias path, cmdinfo result shape, queried-command failure attribution, and `-return dict` parse-status returns (G-049 - its GAP pins flipped 2026-07-10); the flipped G-051 pins for pseudo-command cmdtype (`doconly`) + space-form docid prefixes (real `string is` pins behind the have_tclcoredocs constraint); the G-166 unavailability marking on the helpstrua fixture and on the real `string is dict` subject - the `-return dict` key, the table/string marking with the info scheme suppressed so a valid argument tail no longer renders as a cleanly usable command line, and the `-return text` leading `UNAVAILABLE:` line (the two deliberate key-list flips - `cmdhelp_cmdinfo_result_shape`, `cmdhelp_return_dict_valid` - landed with it); remaining GAP pins for TclOO undocumented-method fallback (G-052) and synopsis marking absence (G-050); plus the G-150 flag-led form-narrowing + selection-soundness pins against punk::auto_exec::hash - bare-parse selection contract (incl the dash-led-name noformmatch cost), no-word/unknown-flag/ambiguity whole-render fallbacks, and hash runtime-behaviour-unchanged), and name/path primitive characterization (`nsprimitives.test`: string pins for nsparts/nsprefix/nstail/nsjoin/nsjoinall incl weird colon-run (`:::`) splitting, the trailing-colon parse ambiguity (`::x:` + `y` joins to the same string as `::x` + `:y` and reparses leading-colon-greedy), and prefix/tail/join round-trip and its absolutizing exceptions (the original twin-divergence pins for nsparts1/nsprefix1/nsprefix_orig/nstail1/nstail_orig served as safe-deletion evidence and were removed with the twins in the punk::ns 0.7.0 hygiene pass - divergence record in this file's git history, commit 0c7168a1); plus nseval fq-requirement/create-on-eval/evaluator-proc caching, the native-vs-punk `p:::x` resolution divergence (native namespace eval reaches child `x`, nseval creates/reaches literal `:x`), nseval_ifexists no-create + error propagation on plain and genuinely weird namespaces, nsexists/nschildren/nstree_raw weird-ns and relative-resolution pins, globmatchns `*`/`**`/`?` semantics (incl `*` matching a single inner colon - a formerly stale 'should be fixed' comment above nsglob_as_re was corrected in the 0.7.0 hygiene pass), and nspath_to_absolute/nspath_here_absolute caller-resolution pins; the nsjoinall error-message wart pin ('nsjoin:' prefix) flipped when punk::ns 0.7.1 fixed it) diff --git a/src/tests/modules/punk/args/testsuites/args/multipleranges.test b/src/tests/modules/punk/args/testsuites/args/multipleranges.test new file mode 100644 index 00000000..c3c2cb24 --- /dev/null +++ b/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 ? 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 \ No newline at end of file