Browse Source

punk::args 0.12.5 + punk::ns 0.8.1: parse -cache overhaul, synopsis notation, eg resolution

parse -cache overhaul:
- VALIDATION trap's parse_cache write now explicitly gated on -cache (formerly
  unconditional but landing in a discarded proc-local for -cache 0 by scoping
  accident - 'variable parse_cache' only ran on the -cache 1 branch)
- cached validation errors stored as per {errorstyle callertext} render
  variants under a {arglist definition form} main key (-caller dropped from the
  main key - results don't depend on it); a missing variant falls through to a
  fresh render. Previously the first caller's errorstyle and %caller%
  attribution were baked in and replayed verbatim to later callers.
- @dynamic definitions (and legacy leading '-dynamic 1') are never cached so
  re-evaluated substitutions always take effect; dev/dynamic-cache.test
  known-bug pin flipped
- new punk::args::parse_cache view/clear proc (sgr_cache style); guarded
  variable init; -errorstyle definition default corrected to the operative
  'standard'. New parsecache.test (7 tests).

synopsis notation + internal @form -synopsis retirement:
- ::punk::args::parse/::parse_status dropped their stale @form -synopsis
  overrides (omitted -cache/-caller) for auto-generated lines shaped with
  -typesynopsis {<int|formname>...} on -form; drift pin
  synopsis_parse_generated added
- punk::ns::synopsis no longer errors on custom @form -synopsis lines that are
  not valid Tcl lists (e.g Tcl manpage style '?-form {int|<formname>...}?' -
  's parse' raised 'list element in braces followed by ...'): non-list lines
  take a textual command-head replacement fallback
- @form -synopsis directive help rewritten (display-only, any style, prefer
  auto-generation, known warranted case: script-level/constructed ids);
  ::punk::args::synopsis -help gains a notation legend and a new @examples
  block comparing Tcl man-page ?...? style with the bracket style (punk::args::eg)

eg usability + examples indication:
- punk::args::eg resolves relative/unqualified command names (punk::ns::cmdinfo
  from calling context when loaded, else global qualification) - in namespaces
  where it shadows the global 'eg' alias (e.g ::punk::args) 'eg list' returned
  empty while 'i list' worked; unknown ids now return a message naming the id;
  punk::ns::eg hands the typed name through on unresolved docids
- usage-table 'Example: eg <id>' header row now uses scheme colour CLR(check)
  (interim display until G-088 footer/subtitle support)

Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com
master
Julian Noble 1 week ago
parent
commit
c2574a7050
  1. 253
      src/modules/punk/args-999999.0a1.0.tm
  2. 5
      src/modules/punk/args-buildversion.txt
  3. 55
      src/modules/punk/ns-999999.0a1.0.tm
  4. 3
      src/modules/punk/ns-buildversion.txt
  5. 28
      src/tests/modules/punk/args/testsuites/args/examples.test
  6. 188
      src/tests/modules/punk/args/testsuites/args/parsecache.test
  7. 66
      src/tests/modules/punk/args/testsuites/args/synopsis.test
  8. 5
      src/tests/modules/punk/args/testsuites/dev/dynamic-cache.test

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

@ -767,8 +767,9 @@ tcl::namespace::eval punk::args {
are re-evaluated each time the definition is resolved.
Use this when defaults, choices, help text, or other definition
fields depend on values that can change after definition time.
Dynamic definitions have a small performance cost and can interact
with punk::args::parse -cache 1; see the parse -cache option.
Dynamic definitions have a small performance cost, and their
parses are never cached - punk::args::parse -cache 1 is ignored
for them so re-evaluated substitutions always take effect.
%B%@normalize%N%
Bare directive (no options), conventionally placed near the top.
Opts the definition into indent normalization of BLOCK-FORM
@ -821,8 +822,23 @@ tcl::namespace::eval punk::args {
(used for commands with multiple forms)
directive-options: -form <list> -synopsis <string>
-overlapallowed <formname-list>
The -synopsis value allows overriding the auto-calculated
synopsis.
The -synopsis value replaces the auto-calculated synopsis
line for the form. It is display-only text: any style is
permitted (including Tcl manual-page style ?...? optionals)
and it need not be a valid Tcl list. The leading word(s)
should be the command invocation - display helpers such as
punk::ns::synopsis substitute the command as the user typed
it for them.
Prefer the auto-calculated synopsis where possible - it
stays current as arguments are added, while an override
silently goes stale. To shape individual elements of the
auto-calculated line use -typesynopsis on the arguments
concerned instead of overriding wholesale (see the notation
legend in punk::args::synopsis).
A known case where an override is warranted: script-level
or constructed definition ids, where the auto-synopsis
renders the raw id (e.g '(script)::punkboot::modules')
rather than an invocable command name.
The -overlapallowed value names other forms this form is
KNOWN to overlap with (an argument list can cleanly match
both - e.g 'after cancel <id|script>' where real Tcl
@ -5320,7 +5336,14 @@ tcl::namespace::eval punk::args {
}
if {$has_example} {
lappend blank_header_col ""
set example_display "[a+ white]eg [dict get $spec_dict id]$RST"
#short runnable hint that an @examples block exists. The raw id is used
#rather than @cmd -name: -name is display text and not always invocable
#(e.g the tclcore moduledoc's 'Autoloading Built-in: parray'). Scheme
#colour (check) so the affordance stands out and NOCOLOR handling applies.
#A frame-embedded bottom-right marker (like the top 'Usage' title) awaits
#textblock table -subtitle/footer-block support (G-088) - this header row
#is the interim display and retires when G-088 lands.
set example_display "$CLR(check)eg [dict get $spec_dict id]$RST"
} else {
set example_display ""
}
@ -6436,7 +6459,7 @@ tcl::namespace::eval punk::args {
"Arguments to parse - supplied as a single list"
@opts -prefix 0
-form -type list -default * -help\
-form -type list -default * -typesynopsis {<int|formname>...} -help\
"Restrict parsing to the set of forms listed.
Forms are the orthogonal sets of arguments a
command can take - usually described in 'synopsis'
@ -6450,9 +6473,10 @@ tcl::namespace::eval punk::args {
form's failure, and matches against several forms
raise an error naming them - pass a single form to
disambiguate."
#default to enhanced errorstyle despite slow 'catch' (unhappy path) performance
#operative default is 'standard' - from defaultopts in the parse proc body.
#(this definition previously claimed enhanced while the code applied standard)
#todo - configurable per interp/namespace
-errorstyle -type string -default enhanced -choices {enhanced standard basic minimal}
-errorstyle -type string -default standard -choices {enhanced standard basic minimal}
-caller -type string -default "" -help\
"Caller attribution for validation error messages.
When non-empty, this string replaces the %caller% placeholder in
@ -6462,20 +6486,20 @@ tcl::namespace::eval punk::args {
internal parse call site)."
-cache -type boolean -default 0 -help\
{Use sparingly.
This caches the entire parse result or formatted validation error for the
same argument list, definition, and selected form.
This caches the entire parse result for the same argument list, definition,
and selected form. Rendered validation errors are cached per errorstyle and
caller attribution.
It is a minor speedup suitable for functions that repeatedly parse a small
set of identical, generally small argument lists against a stable
definition.
Avoid it for one-off calls, large or highly variable argument lists, and
definitions whose dynamic substitutions must be re-evaluated on every call.
In particular, @dynamic definitions may still refresh their resolved
specification data internally, but -cache 1 can return a previously cached
parse result before those refreshed values affect the current call.}
Avoid it for one-off calls and large or highly variable argument lists.
Definitions marked @dynamic (or using the legacy leading '-dynamic 1' form)
are never cached - their substitutions are re-evaluated on every parse.
The cache can be viewed/cleared with punk::args::parse_cache.}
@values -min 2
@form -form withid -synopsis "parse arglist ?-form {int|<formname>...}? ?-errorstyle <choice>? withid $id"
@form -form withid
@values -max 2
withid -type literal(withid) -help\
"The literal value 'withid'"
@ -6483,7 +6507,7 @@ tcl::namespace::eval punk::args {
"id of punk::args definition for a command"
@form -form withdef -synopsis "parse arglist ?-form {int|<formname>...}? ?-errorstyle <choice>? withdef $def ?$def?"
@form -form withdef
withdef -type literal(withdef) -help\
"The literal value 'withdef'"
@ -6497,7 +6521,10 @@ tcl::namespace::eval punk::args {
how to process the definition."
}]
variable parse_cache [dict create]
variable parse_cache
if {![info exists parse_cache]} {
set parse_cache [tcl::dict::create]
}
proc parse {args} {
#puts "punk::args::parse --> '$args'"
if {[llength $args] < 3} {
@ -6585,26 +6612,51 @@ tcl::namespace::eval punk::args {
error "punk::args::parse - invalid call. Argument following arglist was '$tailtype'. Must be 'withid' or 'withdef'"
}
}
set do_cache [dict get $opts -cache]
if {$do_cache} {
#@dynamic definitions (and the legacy leading '-dynamic 1' form) can resolve
#differently for identical raw text - never cache their parses, so
#re-evaluated substitutions always take effect.
variable rawdef_cache_about
if {[dict exists $rawdef_cache_about $deflist]} {
if {[dict get $rawdef_cache_about $deflist -dynamic]} {
set do_cache 0
}
} elseif {[rawdef_is_dynamic $deflist]} {
set do_cache 0
}
}
try {
#puts stdout "parse --> get_dict <deflist> $parseargs -form [dict get $opts -form]"
if {![dict get $opts -cache]} {
if {!$do_cache} {
set result [punk::args::get_dict $deflist $parseargs -form [dict get $opts -form]]
} else {
variable parse_cache
set key [list $parseargs $deflist [dict get $opts -form] [dict get $opts -caller]]
#parse results don't depend on -errorstyle or caller attribution, so the
#main key excludes them. Rendered validation errors bake both in - error
#entries hold per {errorstyle callertext} render variants (see the
#VALIDATION trap); a missing variant falls through to a fresh parse whose
#trap renders and caches it.
set key [list $parseargs $deflist [dict get $opts -form]]
if {[dict exists $parse_cache $key]} {
set cached [dict get $parse_cache $key]
if {[dict get $cached type] eq "result"} {
return [dict get $cached value]
}
set matched_errorstyle [tcl::prefix::match -error "" {enhanced standard basic minimal debug} [dict get $opts -errorstyle]]
if {[dict get $opts -caller] ne ""} {
set caller_text [dict get $opts -caller]
} else {
set caller_text [Get_caller]
}
if {[dict exists $cached value [list $matched_errorstyle $caller_text]]} {
#return the error 'elist'
return {*}[dict get $cached value]
return {*}[dict get $cached value [list $matched_errorstyle $caller_text]]
}
} else {
set result [punk::args::get_dict $deflist $parseargs -form [dict get $opts -form]]
dict set parse_cache $key [dict create type "result" value $result]
return $result
}
set result [punk::args::get_dict $deflist $parseargs -form [dict get $opts -form]]
dict set parse_cache $key [dict create type "result" value $result]
return $result
}
} trap {PUNKARGS VALIDATION} {msg erroropts} {
set opt_errorstyle [dict get $opts -errorstyle]
@ -6620,10 +6672,14 @@ tcl::namespace::eval punk::args {
if {[dict get $opts -caller] ne ""} {
#explicit caller attribution supplied (e.g punk::ns::cmdhelp usage display
#naming the queried command) - frame walking would name internal call sites.
set msg [string map [list %caller% [dict get $opts -caller]] $msg]
set caller_text [dict get $opts -caller]
} else {
set msg [string map [list %caller% [Get_caller]] $msg]
set caller_text [Get_caller]
}
#caller_text is baked into the rendered message below - cached error entries
#are therefore keyed per {errorstyle caller_text} variant so one caller/style
#never replays another's render.
set msg [string map [list %caller% $caller_text] $msg]
#G-041: display-form selection for the usage render. For the multi-form
#candidacy failures the engine reports, show the candidate forms rather than
#the caller's (usually *) -form selection - best candidate first so its
@ -6706,8 +6762,18 @@ tcl::namespace::eval punk::args {
}
}
set key [list $parseargs $deflist [dict get $opts -form] [dict get $opts -caller]]
dict set parse_cache $key [dict create type "error" value $elist]
if {$do_cache} {
variable parse_cache
set key [list $parseargs $deflist [dict get $opts -form]]
set variantkey [list $matched_errorstyle $caller_text]
if {[dict exists $parse_cache $key] && [dict get $parse_cache $key type] eq "error"} {
dict set parse_cache $key value $variantkey $elist
} else {
#no entry, or a stale 'result' entry (same key can only flip outcome if
#the definition's behaviour changed underneath us) - replace wholesale
dict set parse_cache $key [dict create type "error" value [dict create $variantkey $elist]]
}
}
return {*}$elist
} trap {PUNKARGS} {msg erropts} {
append msg \n "Unexpected PUNKARGS error"
@ -6721,6 +6787,59 @@ tcl::namespace::eval punk::args {
return $result
}
lappend PUNKARGS [list {
@id -id ::punk::args::parse_cache
@cmd -name punk::args::parse_cache\
-summary\
"View or clear the punk::args::parse -cache 1 result cache"\
-help\
"Convenience function to view and optionally clear the cache used by
punk::args::parse -cache 1.
Entries are keyed by {arglist definition form} and hold either a
complete parse result or a set of rendered validation-error variants
keyed by {errorstyle callertext}."
@opts
-action -default "" -choices {clear} -help\
"-action clear will reset the punk::args::parse_cache dict to empty"
-return -default summary -type string -choices {summary keys dict} -help\
"summary - dict of counts: entries, results, errors, error_variants, chars
keys - list of cache keys ({arglist definition form} triples)
dict - the underlying cache dict"
@values -min 0 -max 0
}]
proc parse_cache {args} {
#uncached self-parse - so this function doesn't add entries to what it reports on
set argd [punk::args::parse $args withid ::punk::args::parse_cache]
set action [dict get $argd opts -action]
variable parse_cache
if {$action eq "clear"} {
set parse_cache [tcl::dict::create]
return "parse_cache cleared"
}
switch -- [dict get $argd opts -return] {
keys {
return [dict keys $parse_cache]
}
dict {
return $parse_cache
}
summary {
set results 0
set errors 0
set variants 0
dict for {k v} $parse_cache {
if {[dict get $v type] eq "result"} {
incr results
} else {
incr errors
incr variants [dict size [dict get $v value]]
}
}
return [dict create entries [dict size $parse_cache] results $results errors $errors error_variants $variants chars [string length $parse_cache]]
}
}
}
#classify a PUNKARGS VALIDATION failure class-word into the parse-status overall status
#payload = elements of the errorcode description after the class word
proc private::parse_status_classify {failureclass payload} {
@ -7530,7 +7649,7 @@ tcl::namespace::eval punk::args {
arglist -type list -optional 0 -help\
"Arguments to parse - supplied as a single list"
@opts -prefix 0
-form -type list -default * -help\
-form -type list -default * -typesynopsis {<int|formname>...} -help\
"Restrict parsing to the set of forms listed (see punk::args::parse).
The per-argument statuses are built for the first matching form."
-caller -type string -default "" -help\
@ -7540,14 +7659,14 @@ tcl::namespace::eval punk::args {
walk parse would perform."
@values -min 2
@form -form withid -synopsis "parse_status arglist ?-form {int|<formname>...}? ?-caller <string>? withid $id"
@form -form withid
@values -max 2
withid -type literal(withid) -help\
"The literal value 'withid'"
id -type string -help\
"id of punk::args definition for a command"
@form -form withdef -synopsis "parse_status arglist ?-form {int|<formname>...}? ?-caller <string>? withdef $def ?$def?"
@form -form withdef
withdef -type literal(withdef) -help\
"The literal value 'withdef'"
def -type string -multiple 1 -optional 0 -help\
@ -11702,9 +11821,29 @@ tcl::namespace::eval punk::args {
set id [lindex $cmditems 0]
set cmdargs [lrange $cmditems 1 end]
if {$id ne "" && ![string match ::* $id]} {
#punk::args ids are canonically fully-qualified. Accommodate relative command
#names for shell use - callers in a namespace where this proc shadows the
#global 'eg' alias (e.g whilst in ::punk::args itself) would otherwise have to
#fully qualify while 'i'/cmdhelp resolves fine. Resolve from the calling
#context via punk::ns::cmdinfo when punk::ns is loaded (also handles
#subcommand words e.g 'dict filter'), else assume the global namespace.
set resolved ""
if {[info commands ::punk::ns::cmdinfo] ne ""} {
catch {
set resolved [dict get [uplevel 1 [list ::punk::ns::cmdinfo {*}$cmditems]] docid]
}
}
if {$resolved ne ""} {
set id $resolved
} else {
set id ::$id
}
}
set spec [get_spec $id]
if {$spec eq ""} {
return
return "no punk::args definition found for $id"
}
set spec [private::expand_display_fields $spec] ;#G-046 @examples -help may be deferred
if {[dict exists $spec examples_info -help]} {
@ -11874,6 +12013,27 @@ tcl::namespace::eval punk::args {
on separate lines.
If -form <formname> is given, supply only
the synopsis for that form.
Notation used in generated synopsis lines:
argname italicised placeholder - the argument's name
<type> italicised placeholder - shown by type when no
name hint is available
word unitalicised word - a literal, typed as-is.
literal()/literalprefix() types and restricted
choice sets of up to 3 choices render as literals
a|b alternates - exactly one may be supplied
(a|b) alternates grouped in parentheses where a plain
pipe would be ambiguous - e.g an option's value
alternates vs option-alias pipes such as -f|--file
[element] optional element
[element]... optional element, repeatable
elem [elem]... required element, repeatable
--opt=<type> long option accepting an inline =value
{a b} braces protect a multi-word literal - supply it
as a single word
text* / *text value must start / end with 'text'
Custom @form -synopsis overrides are passed through verbatim and
may use any style (see punk::args::define).
"
@opts
-noheader -type none
@ -11881,6 +12041,33 @@ tcl::namespace::eval punk::args {
-return -type string -default full -choices {full summary dict}
@values -min 1 -max -1
cmditem -multiple 1 -optional 0
@examples -help {
Comparison with the Tcl manual-page synopsis style.
Tcl man pages mark an optional element by wrapping it in question
marks; punk::args wraps it in square brackets. With nesting, the
bracket form stays readable because the opening and closing
delimiters differ, whereas the question mark is the same character
at both ends:
Tcl style: lseq start ??'..'|'to'? end? ??'by'? step?
punk::args: lseq start [['..'|'to'] end] [['by'] step]
A generated example (abridged from 's textblock::frame'):
::textblock::frame [-type (choice|<dict>)] [-title <string>] [contents]
-type accepts alternates: 'choice' is an unitalicised literal,
<dict> an italicised type placeholder, and the group is
parenthesized so its pipe is not read as an option-alias pipe
(compare -f|--file). 'contents' is an optional value displayed by
its argument name.
An explicit @form -synopsis override is passed through verbatim -
any style may be used there, including Tcl manual-page style:
@form -form range -synopsis "lseq start ?('..'|'to')? end ??'by'? step?"
}
}]
proc synopsis {args} {
#synopsis potentially called repeatedly with same args? use -cache 1

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

@ -1,6 +1,9 @@
0.12.4
0.12.5
#First line must be a semantic version number
#all other lines are ignored.
#0.12.5 - eg usability: punk::args::eg resolves relative/unqualified command names (via punk::ns::cmdinfo from the calling context when punk::ns is loaded - handles subcommand words too - else global qualification). Rationale: in a namespace where punk::args::eg shadows the global 'eg' alias (e.g whilst in ::punk::args itself) 'eg list' hit the raw-id API and returned empty while 'i list' resolved - Tcl name resolution, not an alias deficiency. Unknown ids now return a message naming the id instead of empty; punk::ns::eg hands the typed name through when resolution yields no docid so the message can name it. The usage-table 'Example: eg <id>' header row (the short indication an @examples block exists) now uses scheme colour CLR(check) instead of hardcoded white - the raw id is kept (not @cmd -name, which is display text and not always invocable e.g 'Autoloading Built-in: parray'); a frame-embedded bottom-right marker awaits textblock table footer-block support (candidate goal). New examples.test pins: relative-name resolution, unknown-id message.
#0.12.5 - synopsis notation documented + internal @form -synopsis overrides retired. ::punk::args::parse and ::parse_status dropped their @form -synopsis overrides - they had gone stale (omitted -cache/-caller) and their only added value (the -form member hint) is now expressed properly via -typesynopsis {<int|formname>...} on the -form option; the auto-generated lines stay current as options are added (drift pin: synopsis.test synopsis_parse_generated). @form -synopsis remains fully supported for documentors: define's directive help now documents that it is display-only text needing no Tcl-list validity, that display helpers substitute the command head, and the known warranted case (script-level/constructed ids rendering the raw id - the G-030 lesson recorded in src/modules/AGENTS.md; head-render preference for @cmd -name remains an open generation deficiency). ::punk::args::synopsis -help gains a notation legend ([...] optional, italic argname/<type> placeholders, unitalicised literals, (a|b) alternate grouping to disambiguate from -f|--file alias pipes, {a b} multi-word literal protection, --opt=<type>, text*/*text) and a new @examples block comparing Tcl man-page ?...? style (ambiguous when nested - same delimiter both ends) with the bracket style, viewable via punk::args::eg. New synopsis.test pins: non-list custom -synopsis passthrough + ns head-replacement survival, parse generated-line drift pin, examples block presence.
#0.12.5 - parse -cache overhaul. (1) The VALIDATION trap's parse_cache write is now explicitly gated on -cache - previously it was unconditional but only 'worked' by scoping accident ('variable parse_cache' was declared solely on the -cache 1 branch, so -cache 0 failures wrote to a discarded proc-local). (2) Cached validation errors are now stored as per {errorstyle callertext} render variants under a main key of {arglist definition form} (-caller removed from the main key - results don't depend on it): previously the rendered message baked in the first caller's errorstyle and %caller% attribution and replayed them verbatim to later callers wanting a different style/caller (e.g. a minimal-style render replayed to a -errorstyle standard call). A missing variant falls through to a fresh parse whose trap renders and caches it. (3) @dynamic definitions (and legacy leading '-dynamic 1') are never cached - parse -cache 1 is ignored for them so re-evaluated substitutions always take effect (dev/dynamic-cache.test known-bug pin flipped). (4) New punk::args::parse_cache convenience proc (view summary/keys/dict, -action clear - sgr_cache style); parse_cache variable init now guarded with info exists like the sibling caches; parse's -errorstyle definition default corrected to 'standard' matching the operative defaultopts (doc previously claimed enhanced). New testsuite parsecache.test.
#0.12.4 - resolve: retired the (already disabled) blanket 'called with undefined id' stderr warning - it fired on the supported inline usages (parse ... withdef, direct resolve calls) where a first-sighting cache miss is by design. In its place the miss path is now collision-aware: a first-seen rawdef whose @id is already registered with different content purges the id's existing cache entries (quiet undefine) before registering, mirroring define's re-creation behaviour - previously it repointed id_cache_rawdef while leaving the old rawdef_cache_about/rawdef_cache_argdata entries in place, so two rawdefs claimed the same id and a subsequent define with the original text was a silent no-op (stale cache-about hit) leaving withid lookups stuck on the inline definition. Guard tests id_cache_rawdef directly (id_exists also matches aliases, where the rawdef lookup would error). Known residual (comment marker retained): the miss path still records no -defspace, so @dynamic definitions first seen via resolve lack a defining namespace for re-substitution. New test define.test define_resolve_id_shadow (characterized pre-fix: re-define no-op reproduced with the purge disabled).
#0.12.3 - fixed the stringstartswith(*) arm of get_dict_can_assign_value's multi-member tail-clause reservation walk: it matched against $tp (the type string) instead of $rv (the candidate value), so for a later optional clause mixing literal(...) and stringstartswith(...) members, genuinely matching trailing words were never reserved from a greedy -multiple argument (the optional tail clause was silently lost), while a prefix text that happened to prefix the literal type string (e.g 'str' vs 'stringstartswith(str)') wrongly reserved non-matching words and raised a spurious toomanyarguments overflow. Found flagged-only in the 0.12.2 comment review; characterized pre-fix then pin flipped (allocation.test allocation_tailclause_ssw_reservation + literal-only and no-match guards). Note the walk still only runs when the tail clause's typelist contains a literal* member (the pre-existing acknowledged approximation) - a stringstartswith-only tail clause remains unreserved.
#0.12.2 - comment/documentation hygiene pass (no behaviour change). Removed ~1100 lines of superseded commented-out code: the old-signature private::check_clausecolumn, the abandoned _check_clausecolumn2 'interim version' experiment (its one keeper idea - collect per-clause validation results and report at end instead of raising on first failure - recorded in an in-place note for G-072-era rework), the pre-parsekey opts ordering loop, a disabled XXXliteral* switch arm in get_dict_can_assign_value's multi-member clause walk (open item noted: literalprefix() members unhandled there), and misc debug remnants. Expanded the leaders/values loop MAINTENANCE notes into a real refactor assessment: shared clause-assignment helper extraction is indicated (drift is real - G-082 duplicated its selection block, parsekey idents exist only values-side) but deferred to ride the G-084 leaders/values parsekey parity work under the G-046 hot-path constraints; until then edits to one loop must be mirrored or justified. Documentation corrections: get_dict PUNKARGS/doctools return shape now lists the real result keys (leaders opts values received solos multis id form + formstatus when candidacy ran); directive lists updated beyond @cmd/@leaders/@opts/@values; removed false pre-modern claims ('only supports -flag val pairs, not solo options', 'only the last value is used'); fixed the broken parse -help example (dict exists bracketing) and the module-header dofilestuff example (stray trailing 1 after -type existingfile); assorted typos. Goal cross-references annotated in code comments (G-053 -multiple occurrence ranges, G-084 values/leaders parsekey, corrected test-name refs to parsekey_repeat_ordering). Flagged (comment only, behaviour unchanged, no pins exist): suspected bug in the multi-member clause stringstartswith( arm - it string-matches against $tp (the type) instead of $rv (the value), so it always takes the success path.

55
src/modules/punk/ns-999999.0a1.0.tm

@ -5184,6 +5184,11 @@ y" {return quirkykeyscript}
#set resolved_id [dict get $resolveinfo origin]
#set result [::punk::args::eg $resolved_id]
set docid [dict get $resolveinfo docid]
if {$docid eq ""} {
#undocumented or unresolved - hand the typed name through so
#punk::args::eg's 'no punk::args definition' message can name it
set docid [lindex $cmdwords 0]
}
set result [::punk::args::eg $docid]
}
@ -5292,25 +5297,41 @@ y" {return quirkykeyscript}
#append resultstr [join [lreplace $synline 0 $replaceuntil {*}$resolved_args] " "] \n ;#don't use join - will destroy braced sets
#e.g see s dict filter
#treating a somewhat arbitrary string $synline as a list here is a bit risky
#todo - consider always using 'punk::args::synopsis -return dict' and operating on that list to rebuild string - REVIEW
set adjusted_synline [lreplace $synline 0 $replaceuntil {*}$resolved_args] ;#don't use join - will destroy braced sets
#however - we don't want the extra bracing around ansi elements caused by list rep!
#::dict filter {dictionaryValue} script {keyVariable valueVariable} {script}
#vs
#::dict filter dictionaryValue script {keyVariable valueVariable} script
#(due to ansi in dictionaryValue and trailing script)
#manually join based on list length review
set lineout ""
foreach part $adjusted_synline {
if {[llength $part] == 1} {
append lineout " " $part
} else {
append lineout " " [list $part]
#custom @form -synopsis lines are arbitrary display text and need not
#be valid Tcl lists (e.g Tcl manpage style '?-opt {a|b}?' puts a
#braced group hard against a question mark) - list operations would
#raise 'list element in braces followed by ...'. For such lines
#replace the leading command word(s) textually and pass the tail
#through verbatim.
if {[catch {llength $synline}]} {
set tailstart 0
set count 0
while {$count <= $replaceuntil && [regexp -indices -start $tailstart {\S+} $synline wordindices]} {
set tailstart [expr {[lindex $wordindices 1]+1}]
incr count
}
set lineout [string trim "[join $resolved_args " "] [string trimleft [string range $synline $tailstart end]]"]
} else {
#treating a somewhat arbitrary string $synline as a list here is a bit risky
#todo - consider always using 'punk::args::synopsis -return dict' and operating on that list to rebuild string - REVIEW
set adjusted_synline [lreplace $synline 0 $replaceuntil {*}$resolved_args] ;#don't use join - will destroy braced sets
#however - we don't want the extra bracing around ansi elements caused by list rep!
#::dict filter {dictionaryValue} script {keyVariable valueVariable} {script}
#vs
#::dict filter dictionaryValue script {keyVariable valueVariable} script
#(due to ansi in dictionaryValue and trailing script)
#manually join based on list length review
set lineout ""
foreach part $adjusted_synline {
if {[llength $part] == 1} {
append lineout " " $part
} else {
append lineout " " [list $part]
}
}
set lineout [string trim $lineout]
}
set lineout [string trim $lineout]
#G-041: underline the form(s) the supplied trailing words match
if {[llength $markforms] && [lindex $docid_forms $formidx] in $markforms} {
set lineout "[punk::ansi::a+ underline]$lineout[punk::ansi::a+ nounderline]"

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

@ -1,6 +1,7 @@
0.8.0
0.8.1
#First line must be a semantic version number
#all other lines are ignored.
#0.8.1 - synopsis: fixed 'list element in braces followed by "?" instead of space' error when rendering a command whose definition carries a custom @form -synopsis that is not a valid Tcl list (e.g Tcl manpage style '?-form {int|<formname>...}?' as ::punk::args::parse formerly used - 's parse' errored). The command-head replacement treated each synopsis line as a Tcl list (lreplace); non-list lines now take a textual fallback that replaces the leading word(s) via index walking and passes the tail through verbatim. Pinned in punk/args synopsis.test (synopsis_form_custom_override_nonlist) since that suite already exercises the punk::ns::synopsis path.
#0.8.0 - removed the global command aliases nscommands1 and nscommands2 (user direction 2026-07-14, flagged as removal candidates in the 0.7.0 hygiene pass): earlier pipeline-based iterations superseded by the plain proc nscommands (which adds weird-namespace handling). Neither was documented/exported and no callers existed; nscommands1 was broken - it referenced nonexistent commands (nsthis2, inspect at global scope) and errored on first use. Recoverable via git history.
#0.7.1 - hygiene-pass backlog fixes (both pinned before the fix, pins flipped with it): (a) get_nslist's -types default was the literal string $known_types (braced-literal defaults bug) so any call without -types - notably bare user-level 'nslist' - errored with a malformed 'Unrecognised namespace member type: $known_types'; an unsupplied -types now defaults to all known member types (nslist.test nslist_types_default; nslist's own PUNKARGS -types gained help text and deliberately no -default so the passthrough stays absent when unsupplied). (b) nsjoinall's non-empty-prefix-vs-absolute-path error message carried a copy-paste 'nsjoin:' prefix - now 'nsjoinall:' (nsprimitives.test nsjoinall_edges). Also commented out a development trace puts ('>>> base: ...') that polluted nscommands stdout on the weird-namespace path (same treatment as the 0.1.1 trace-puts cleanup).
#0.7.0 - comment/doc hygiene pass (no change to shipped behaviour; minor bump for removed unused procs). Dead code removed (recoverable via git history): the fully commented-out predecessor help command 'arginfo' (~930 lines, replaced by cmdhelp), the superseded/deprecated name-primitive twins nsparts1/nsprefix1/nsprefix_orig/nstail1/nstail_orig (never exported, no callers; NOT drop-in equivalents - they collapse colon runs of 5+ differently, divergence pinned in tests ns/nsprimitives.test git history), obsolete nsglob_as_re1, empty stubs ns_relative_to_location/ns_absolute_to_location, obsolete internal::_pkguse_vars, an 'if 0' block in nstree_list, unused interp_aliases assignment in get_ns_dicts, and assorted commented-out code remnants. Stale docs corrected: nsprefix comment (:::a prefix is :: not ::a), nsglob_as_re 'should be fixed' note (fix has long been in place - * matches an inner single colon), cmdhelp typos, corp -syntax truncated help sentence, nstree_list empty PUNKARGS help fields filled. Flagged (comment only): get_nslist red-strike/masked alias display branches appear unreachable (see nslist.test fixture findings), nscommands1/nscommands2 pipeline aliases are superseded removal candidates (nscommands1 errors on use - references nonexistent nsthis2), cmdtrace stdout/stderr noise deferred to proposed G-085, test_switch* marked as live cmdtrace test fixtures. All inserted/updated comments carry 2026-07-14 Agent marks.

28
src/tests/modules/punk/args/testsuites/args/examples.test

@ -134,6 +134,34 @@ namespace eval ::testspace {
}\
-result [list "keep1|keep2"]
#added 2026-07-17: punk::args::eg formerly required a fully-qualified id - callers in a
#namespace where it shadows the global 'eg' alias (e.g whilst in ::punk::args) had to
#type 'eg ::list' while 'i list' resolved fine. It now resolves relative names from the
#calling context (punk::ns::cmdinfo when punk::ns is loaded, else global qualification).
test eg_relative_name_resolution {punk::args::eg resolves a relative/unqualified command name}\
-setup $common -body {
package require punk::args::moduledoc::tclcore
set qualified [punk::args::eg ::lseq]
lappend result [expr {[string length $qualified] > 100}]
lappend result [expr {[punk::args::eg lseq] eq $qualified}]
#from another namespace - still resolves (this is the shadowing scenario)
namespace eval ::testspace::somewhere {
lappend ::testspace::result [expr {[punk::args::eg lseq] eq [punk::args::eg ::lseq]}]
}
set result
}\
-cleanup {
catch {namespace delete ::testspace::somewhere}
}\
-result [list 1 1 1]
#added 2026-07-17
test eg_unknown_id_message {punk::args::eg names the id in its no-definition message instead of returning empty}\
-setup $common -body {
lappend result [punk::args::eg ::testspace::egnonexistent]
}\
-result [list "no punk::args definition found for ::testspace::egnonexistent"]
test tclcore_lseq_examples_live {the tclcore moduledoc's ::lseq definition (a heavy example consumer) resolves, renders, and serves 'eg' content}\
-setup $common -body {
package require punk::args::moduledoc::tclcore

188
src/tests/modules/punk/args/testsuites/args/parsecache.test

@ -0,0 +1,188 @@
#parsecache.test - behaviour of punk::args::parse -cache 1 and the punk::args::parse_cache
#inspection utility (punk::args 0.12.5 cache overhaul).
#Assertions filter cache keys/entries by the test definition's id substring rather than
#asserting global counts - internal punk::args machinery (e.g. synopsis rendering during
#error display) may legitimately add its own -cache 1 entries during a test body.
package require tcltest
namespace eval ::testspace {
namespace import ::tcltest::*
variable common {
set result ""
punk::args::parse_cache -action clear
}
#return {key value ...} for parse_cache entries whose key contains idglob,
#optionally restricted to entries of type 'result' or 'error'.
#NOTE: internal punk::args machinery (e.g. usage-table rendering for standard/enhanced
#errorstyles) makes its own -cache 1 parses whose keys can contain the definition text
#of the id under test - filter by entry type to isolate the entries a test created.
proc pc_entries {idglob {type ""}} {
set matched {}
dict for {k v} [punk::args::parse_cache -return dict] {
if {[string match *${idglob}* $k]} {
if {$type eq "" || [dict get $v type] eq $type} {
lappend matched $k $v
}
}
}
return $matched
}
test parsecache_result_hit {Ensure -cache 1 stores a result entry and the cached path returns an identical result}\
-setup $common -body {
punk::args::define {
@id -id ::testspace::pc_res
@opts -anyopts 0
-x -default 0 -type integer
}
set r1 [punk::args::parse {-x 5} -cache 1 withid ::testspace::pc_res]
set r2 [punk::args::parse {-x 5} -cache 1 withid ::testspace::pc_res]
set entries [pc_entries pc_res result]
list [expr {$r1 eq $r2}] [llength $entries] [dict get [lindex $entries 1] type]
}\
-cleanup {
punk::args::undefine ::testspace::pc_res
punk::args::parse_cache -action clear
}\
-result {1 2 result}
test parsecache_cache0_no_write {Ensure failing parses without -cache add no cache entries (write is gated on -cache)}\
-setup $common -body {
punk::args::define {
@id -id ::testspace::pc_c0
@opts -anyopts 0
-x -default 0 -type integer
}
catch {punk::args::parse {-x notint1} withid ::testspace::pc_c0}
catch {punk::args::parse {-x notint2} -errorstyle minimal withid ::testspace::pc_c0}
#internal rendering machinery may add its own 'result' entries mentioning this
#definition - our uncached failing parses must not have added 'error' entries,
#and no entry may be keyed by our failing arglists.
set leaks 0
foreach k [punk::args::parse_cache -return keys] {
if {[lindex $k 0] in {{-x notint1} {-x notint2}}} {
incr leaks
}
}
list [llength [pc_entries pc_c0 error]] $leaks
}\
-cleanup {
punk::args::undefine ::testspace::pc_c0
punk::args::parse_cache -action clear
}\
-result {0 0}
test parsecache_error_variant_replay {Ensure an identical repeated failing -cache 1 call replays its cached render (single variant, identical message)}\
-setup $common -body {
punk::args::define {
@id -id ::testspace::pc_replay
@opts -anyopts 0
-x -default 0 -type integer
}
catch {punk::args::parse {-x notint} -cache 1 -errorstyle minimal withid ::testspace::pc_replay} m1
catch {punk::args::parse {-x notint} -cache 1 -errorstyle minimal withid ::testspace::pc_replay} m2
set entries [pc_entries pc_replay error]
set entry [lindex $entries 1]
list [expr {$m1 eq $m2}] [llength $entries] [dict get $entry type] [dict size [dict get $entry value]]
}\
-cleanup {
punk::args::undefine ::testspace::pc_replay
punk::args::parse_cache -action clear
}\
-result {1 2 error 1}
test parsecache_errorstyle_variants {Ensure a cached error render is not replayed to a call wanting a different -errorstyle}\
-setup $common -body {
punk::args::define {
@id -id ::testspace::pc_style
@opts -anyopts 0
-x -default 0 -type integer
}
catch {punk::args::parse {-x notint} -cache 1 -errorstyle minimal -caller CST withid ::testspace::pc_style} m_min
catch {punk::args::parse {-x notint} -cache 1 -errorstyle standard -caller CST withid ::testspace::pc_style} m_std
set entry [lindex [pc_entries pc_style error] 1]
list [expr {[string length $m_std] > [string length $m_min]}] [dict size [dict get $entry value]]
}\
-cleanup {
punk::args::undefine ::testspace::pc_style
punk::args::parse_cache -action clear
}\
-result {1 2}
test parsecache_caller_variants {Ensure cached error attribution follows each call's -caller rather than replaying the first caller's render}\
-setup $common -body {
punk::args::define {
@id -id ::testspace::pc_caller
@opts -anyopts 0
-x -default 0 -type integer
}
catch {punk::args::parse {-x notint} -cache 1 -errorstyle minimal -caller ONECMD withid ::testspace::pc_caller} m1
catch {punk::args::parse {-x notint} -cache 1 -errorstyle minimal -caller TWOCMD withid ::testspace::pc_caller} m2
set entry [lindex [pc_entries pc_caller error] 1]
list [string match *ONECMD* $m1] [string match *TWOCMD* $m2] [string match *ONECMD* $m2] [dict size [dict get $entry value]]
}\
-cleanup {
punk::args::undefine ::testspace::pc_caller
punk::args::parse_cache -action clear
}\
-result {1 1 0 2}
test parsecache_dynamic_bypass {Ensure @dynamic definitions are never cached by parse -cache 1}\
-setup $common -body {
variable pc_dyn_list {A B C}
proc ::testspace::pc_dyn_get {} {
variable pc_dyn_list
return $pc_dyn_list
}
namespace eval dynwhatever {
set DYN_LIST {${[::testspace::pc_dyn_get]}}
punk::args::define {
@dynamic
@id -id ::testspace::pc_dyn
@values
param -type list -default "${$DYN_LIST}"
}
}
set argd [punk::args::parse {} -cache 1 withid ::testspace::pc_dyn]
lappend result [dict get $argd values param]
set ::testspace::pc_dyn_list {X Y Z}
set argd [punk::args::parse {} -cache 1 withid ::testspace::pc_dyn]
lappend result [dict get $argd values param]
list $result [llength [pc_entries pc_dyn]]
}\
-cleanup {
namespace delete ::testspace::dynwhatever
rename ::testspace::pc_dyn_get {}
punk::args::undefine ::testspace::pc_dyn
punk::args::parse_cache -action clear
}\
-result [list [list {A B C} {X Y Z}] 0]
test parsecache_utility {Ensure parse_cache summary/keys/dict/clear views behave}\
-setup $common -body {
punk::args::define {
@id -id ::testspace::pc_util
@opts -anyopts 0
-x -default 0 -type integer
}
punk::args::parse {-x 1} -cache 1 withid ::testspace::pc_util
catch {punk::args::parse {-x notint} -cache 1 -errorstyle minimal -caller CST withid ::testspace::pc_util}
set s [punk::args::parse_cache]
lappend result [expr {[dict get $s entries] >= 2}]
lappend result [expr {[dict get $s results] >= 1}]
lappend result [expr {[dict get $s errors] >= 1}]
lappend result [expr {[dict get $s error_variants] >= 1}]
lappend result [expr {[dict get $s chars] > 0}]
lappend result [expr {[llength [punk::args::parse_cache -return keys]] == [dict get $s entries]}]
lappend result [punk::args::parse_cache -action clear]
set s2 [punk::args::parse_cache]
lappend result [dict get $s2 entries]
}\
-cleanup {
punk::args::undefine ::testspace::pc_util
punk::args::parse_cache -action clear
}\
-result {1 1 1 1 1 1 {parse_cache cleared} 0}
}
tcltest::cleanupTests ;#needed to produce test summary line.

66
src/tests/modules/punk/args/testsuites/args/synopsis.test

@ -358,6 +358,72 @@ namespace eval ::testspace {
-result [list\
"# summary\n::testspace::testns::t1 \[-t FOO\]"
]
#added 2026-07-17: custom @form -synopsis strings are arbitrary display text and need
#not be valid Tcl lists (e.g Tcl manpage style '?-a {b|c}?' places a braced group hard
#against a question mark). punk::ns::synopsis formerly raised
#'list element in braces followed by "?" instead of space' on such lines (its command
#head replacement used list operations) - it now falls back to textual head replacement.
test synopsis_form_custom_override_nonlist {a custom @form -synopsis that is not a valid Tcl list passes through punk::args::synopsis verbatim and survives punk::ns::synopsis head replacement}\
-setup $common -body {
namespace eval testns {
punk::args::define {
@id -id ::testspace::testns::tcust
@cmd -summary "custom synopsis test"
@form -form {manual auto}
@leaders
a1 -optional 0
@form -form manual -synopsis "tcust a1 ?-a {b|c}? thing"
@values -min 0 -max 0
@form -form auto
@values
v1 -optional 1
}
}
#direct render includes the custom line verbatim
set direct [punk::args::synopsis ::testspace::testns::tcust]
lappend result [expr {[string first "tcust a1 ?-a {b|c}? thing" $direct] >= 0}]
#the ns-level render (the shell 's' path) must not error on the non-list line,
#and substitutes the command head as typed
set nsout [punk::ns::synopsis ::testspace::testns::tcust]
set lines {}
foreach ln [split $nsout \n] {
if {[string match "#*" $ln]} {continue}
lappend lines $ln
}
lappend result [lindex $lines 0]
}\
-cleanup {
namespace delete ::testspace::testns
}\
-result [list\
1\
"::testspace::testns::tcust a1 ?-a {b|c}? thing"
]
#added 2026-07-17: ::punk::args::parse dropped its @form -synopsis overrides (which had
#gone stale - they omitted -caller and -cache) in favour of the auto-generated line
#shaped with -typesynopsis on -form. This pin catches future drift: a new parse option
#must appear here (that's the point - the auto-synopsis stays current).
test synopsis_parse_generated {::punk::args::parse withid synopsis is auto-generated and lists all current options}\
-setup $common -body {
set syntext [punk::args::synopsis -form withid ::punk::args::parse]
set lines {}
foreach ln [split [punk::ansi::ansistrip $syntext] \n] {
if {[string match "#*" $ln]} {continue}
lappend lines $ln
}
lindex $lines 0
}\
-result {::punk::args::parse arglist [-form (<int|formname>...)] [-errorstyle <string>] [-caller <string>] [-cache <bool>] withid id}
#added 2026-07-17
test synopsis_examples_block {the @examples block of punk::args::synopsis compares Tcl man-page style with punk::args bracket style}\
-setup $common -body {
set eg [punk::args::eg ::punk::args::synopsis]
list [string match "*Tcl style:*" $eg] [string match "*punk::args:*" $eg]
}\
-result {1 1}
}
tcltest::cleanupTests ;#needed to produce test summary line.

5
src/tests/modules/punk/args/testsuites/dev/dynamic-cache.test

@ -8,8 +8,9 @@ namespace eval ::testspace {
set result ""
}
test define_dynamic_cache_refreshes_resolved_defaults {Known bug: @dynamic defaults should refresh when parsed with cache}\
-constraints punkargsKnownBug\
#pin flipped 2026-07-17: punk::args 0.12.5 bypasses the parse -cache for @dynamic
#definitions, so re-evaluated substitutions always take effect.
test define_dynamic_cache_refreshes_resolved_defaults {@dynamic defaults refresh when parsed with -cache 1 (cache bypassed for dynamic definitions)}\
-setup $common -body {
variable test_list
set test_list {A B C}

Loading…
Cancel
Save