Browse Source

punk::args 0.11.2: bad-@dynamic warn-once + round-1 caching; tclcore 0.3.2: fix malformed tcl_startOfNextWord (project 0.12.19)

User-reported: 'i join' emitted the bad-@dynamic warning 4 times ('i
join test' 6). Root cause was a real inefficiency, not just noisy
logging: a @dynamic definition whose round-1 tstr output has no
round-2 parameters bypassed argdefcache_unresolved entirely, so every
resolve in the 'i' flow (doc walk, advisory parse, get_spec for the
render, synopsis) redid display masking plus the full round-1 tstr and
re-warned. Such definitions now cache as a zero-param unresolved entry
- consistent with the round-1 freezing legitimately dynamic
definitions already get - so later resolves take the cheap cached
branch and the warning emits once per definition per interp.

Warning wording corrected: @dynamic is not a complete no-op for a
parse-inert definition - deferred display fields still re-expand per
render instead of caching - so the message says removal is appropriate
only if that display behaviour is unintended. Sweep of all 462
registered ids: ::split, ::array, ::join carry the parse-inert tag
(left in place pending the display-refresh decision).

Incidental finds from the sweep: the malformed ::tcl_startOfNextWord
tclcore definition (double-quoted -help with unescaped quotes in the
embedded man-page example) failed to resolve at all - now braced per
the define quoting rules, 'i tcl_startOfNextWord' works; and resolve's
malformed-record error path printed dev diagnostics (full records dump
+ ::testrecord global) - commented per the stray-debug convention.

Tests: new args/dynamic.test (warn-once + cached parse, legit-dynamic
round-2 freshness; stderr captured via channel transform). punk::args
203/204 (1 pre-existing skip), punk::ns 57/57. Live-verified: 'i join'
and 'i join test' each emit the warning exactly once (punk902z src).

Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com
master
Julian Noble 2 days ago
parent
commit
b35ea12896
  1. 4
      CHANGELOG.md
  2. 2
      punkproject.toml
  3. 23
      src/modules/punk/args-999999.0a1.0.tm
  4. 3
      src/modules/punk/args-buildversion.txt
  5. 4
      src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm
  6. 3
      src/modules/punk/args/moduledoc/tclcore-buildversion.txt
  7. 96
      src/tests/modules/punk/args/testsuites/args/dynamic.test

4
CHANGELOG.md

@ -5,6 +5,10 @@ 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.12.19] - 2026-07-13
- punk::args 0.11.2: the "bad @dynamic tag" warning now emits once per definition instead of on every resolve ('i join' emitted it 4x, user-reported) - the underlying inefficiency fixed: parse-inert @dynamic definitions now cache their round-1 resolution like legitimately dynamic ones instead of redoing the full text substitution on every resolve. The warning wording now notes @dynamic still forces display-field re-expansion per render for such definitions. tclcore moduledoc 0.3.2: fixed the malformed ::tcl_startOfNextWord definition (unescaped quotes in its embedded man-page example prevented it resolving at all - 'i tcl_startOfNextWord' now works), found by sweeping all 462 registered ids; the sweep identified ::split, ::array and ::join as the parse-inert @dynamic carriers.
## [0.12.18] - 2026-07-13
- tclcore moduledoc 0.3.1 (authoring-style only): the after id-shape harvest is consumed via tstr placeholders with the variable in the argdoc namespace (the defspace), replacing the interim string-map token - the module now showcases the neater placeholder style documented in punk::args 0.11.1, with string map reserved for when build-time substitution is genuinely necessary. Behaviour unchanged.

2
punkproject.toml

@ -1,4 +1,4 @@
[project]
name = "punkshell"
version = "0.12.18"
version = "0.12.19"
license = "BSD-2-Clause"

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

@ -2152,7 +2152,14 @@ tcl::namespace::eval punk::args {
tcl::dict::set argdefcache_unresolved $cache_key [list {*}$pt_params $deferred_display]
} else {
#wasn't really a 'dynamic' definition - no 2nd round parameter substitution in definition
puts stderr "punk::args::resolve - bad @dynamic tag for id:$id - no 2nd round substitution required"
#(@dynamic is not a complete no-op for such a definition: deferred
# display fields still re-expand per render instead of caching)
puts stderr "punk::args::resolve - bad @dynamic tag for id:$id - no 2nd round substitution required (for this definition @dynamic only forces display-field re-expansion per render - remove it if that is not intended)"
#Cache as a zero-param unresolved entry so subsequent resolves take the
#cached branch above: no re-mask/re-tstr of round 1 (the same round-1
#freezing legitimately dynamic definitions get) and this warning emits
#once per definition instead of on every resolve.
tcl::dict::set argdefcache_unresolved $cache_key [list [list $optionspecs] {} $deferred_display]
}
@ -2257,12 +2264,14 @@ tcl::namespace::eval punk::args {
incr record_number
#after first word, the remaining list elements up to the first newline that isn't inside a value, form a dict
if {[catch {set record_values [lassign $trimrec firstword]}]} {
puts stdout "----------------------------------------------"
puts stderr "rec: $rec"
set ::testrecord $rec
puts stdout "----------------------------------------------"
puts "records: $records"
puts stdout "=============================================="
#dev diagnostics for the malformed record - commented per the stray-debug
#convention; the raised message carries the offending record
#puts stdout "----------------------------------------------"
#puts stderr "rec: $rec"
#set ::testrecord $rec
#puts stdout "----------------------------------------------"
#puts "records: $records"
#puts stdout "=============================================="
error "punk::args::resolve - bad optionspecs line - unable to parse first word of record '$trimrec' id:$DEF_definition_id"
}
#set record_values [lassign $trimrec firstword]

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

@ -1,6 +1,7 @@
0.11.1
0.11.2
#First line must be a semantic version number
#all other lines are ignored.
#0.11.2 - bad-@dynamic warn-once + round-1 caching (user-reported: 'i join' emitted the "bad @dynamic tag" warning 4x, 'i join test' 6x): a @dynamic definition whose round-1 tstr output contains no round-2 parameters previously bypassed argdefcache_unresolved entirely, so EVERY resolve redid display masking plus the full round-1 tstr and re-warned - each 'i' invocation resolves several times (doc walk, advisory parse, get_spec for the render, synopsis), all cache hits for static definitions but full re-work for bad-dynamic ones. Such definitions are now cached as a zero-param unresolved entry: subsequent resolves take the cheap cached branch (consistent with the round-1 freezing legitimately dynamic definitions already get) and the warning emits once per definition per interp. Warning message corrected while there: @dynamic is NOT a complete no-op for a parse-inert definition - deferred display fields still re-expand per render instead of caching (expand_display_fields spec_dynamic gate), so the message now says removal is appropriate only if that display behaviour is unintended. Legitimately dynamic definitions unaffected (round-2 re-substitution pinned live). Also commented the dev diagnostics dump (puts of the full records list + ::testrecord global) on resolve's malformed-record error path - the raised message already carries the offending record; found when the malformed ::tcl_startOfNextWord tclcore definition triggered it (fixed in tclcore 0.3.2). New testsuite args/dynamic.test (warn-once + cached-parse pin, legit-dynamic round-2 freshness pin, stderr captured via channel transform).
#0.11.1 - documentation-only: define -help gains a 'Registration styles' section (direct define vs deferred lappend-PUNKARGS + register::NAMESPACES registration - lazy @id scan and on-demand definition via update_definitions, the module-template/moduledoc style, PUNKARGS_aliases, punk::args::status timings; prefer deferred for anything large) and an 'Interpolation (tstr placeholders) and the defspace' section (display-field deferral vs parse-field expansion at first resolve vs @dynamic re-expansion; defspace rule: direct define = calling namespace, registered PUNKARGS = the argdoc child namespace when it exists even if the PUNKARGS variable is in the parent; unresolvable placeholders left silently literal - check the defspace when a literal ${...} appears; robust patterns for load-time-computed values: explicit tstr pre-expansion, build-time string-map token, or setting the variable in argdoc). Motivated by the ::after id-shape harvest misattribution (2026-07-13, corrected record in goals/G-055).
#0.11.0 - G-041 increment 2 (doc-surface support): synopsis renderer honours the documented @form -synopsis override - the stored override now replaces the auto-calculated synopsis line in punk::args::synopsis full and summary renders (arg_error's synopsis section honoured it already; the per-form dict gains a 'synopsis' key when overridden, FORMARGS unchanged for dict consumers) - forms.test GAP forms_form_synopsis_override_stored_not_rendered_GAP flipped to forms_form_synopsis_override_rendered. Candidate-form ranking extended to choice discriminators: form_literal_affinity now treats a required argument with a RESTRICTED choice set as a discriminator (matched via choiceword_match, the shared G-040 resolver) alongside literal()/literalprefix() types - the tclcore models express subcommand-ish literals as -choices (e.g after's cancel/idle/info), so 'after cancel' now ranks the cancel forms first in noformmatch errors and parse_status best-candidate selection instead of falling back to declaration order.
#0.10.0 - G-041 multi-form candidacy: punk::args::parse/get_dict with the default -form * (or any multi-form selection) now attempts every permitted form instead of effectively parsing form 0 only - a clean match against exactly one form is auto-selected, no match raises a 'noformmatch' PUNKARGS VALIDATION error naming each candidate form's first-line failure (candidates ranked best-first: leading-literal affinity with the supplied words, then incomplete before invalid, then declaration order - private::form_selection/form_literal_affinity/rank_form_failures), and several clean matches raise 'multipleformmatches' naming the forms (no silent preference - deliberate, callers pass -form to disambiguate). -form now accepts a list of form names/indices as documented (get_dict, parse, parse_status, arg_error; supplied order preserved - arg_error renders the argument table for the first listed form and marks all listed forms' synopsis entries; parse's error render passes the ranked candidates). Parse results gain a 'form' key (the parsed form; keys appended after 'id' - positional consumers unaffected) and, when candidacy ran, a 'formstatus' key reporting every attempted form's outcome; parse_status 'form' is now the matched/best-candidate form (per-argument statuses and badarg marking built for it) and gains the documented 'formstatus' key (per-form status/failureclass/badarg/message with caller attribution) - the per-form compatibility surface the G-044 completion/hinting consumer needs. Engine body extracted verbatim to private::get_dict_form (single-form parse; argspecs resolved before selection - no caller-frame use). forms.test GAP pins flipped (auto-selection, -form list restriction) + new coverage (shared-prologue arity discrimination, noformmatch ranking/errorcode, multipleformmatches, formstatus in results and parse_status).

4
src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm

@ -257,7 +257,7 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
-summary\
"first start-of-word index after supplied index ${$I}start${$NI}"\
-help\
"Returns the index of the first start-of-word location that occurs after a starting index start
{Returns the index of the first start-of-word location that occurs after a starting index start
in the string str. A start-of-word location is defined to be the first word character following a
non-word character. Returns -1 if there are no more start-of-word locations after the starting point.
@ -268,7 +268,7 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
set idx [tcl_startOfNextWord $theString $idx]} {
puts "Word start index: $idx"
}
"
}
@values -min 2 -max 2
str -type string
start -type indexexpression

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

@ -1,6 +1,7 @@
0.3.1
0.3.2
#First line must be a semantic version number
#all other lines are ignored.
#0.3.2 - fixed malformed ::tcl_startOfNextWord definition: its -help was double-quoted but the embedded man-page example contains inner double quotes (set theString "The quick brown fox" / puts "Word start index: ..."), so the value terminated early and the definition failed to resolve at all (punk::args::resolve 'bad optionspecs line' error; 'i tcl_startOfNextWord' broken). The -help is now braced (fully literal per the define quoting rules - inner quotes and brackets safe, text verbatim, example braces balance). Found by a bad-@dynamic sweep across all 462 registered ids (punk::args 0.11.2 work); the same sweep identified the parse-inert @dynamic tags on ::split, ::array and ::join (each now warns once per interp) - left in place pending a decision on their display-refresh semantics (@dynamic still forces display-field re-expansion per render).
#0.3.1 - authoring-style only (user-directed): the ::after id-shape harvest is consumed via tstr ${$after_id_prefix} placeholders instead of the 0.3.0 build-time %AFTERIDPREFIX% string map - the harvest variable now lives in the argdoc namespace (the defspace registered PUNKARGS definitions resolve placeholders in when an argdoc child exists; see the punk::args::define 'Interpolation and the defspace' help section), which makes plain placeholders work in both the -type parse field (expanded at first resolve) and the -help display fields (expanded at display time). This module showcases the tstr style; string map remains reserved for cases where build-time substitution is genuinely necessary. Behaviour identical to 0.3.0 (resolved -type, form discrimination, parity pins and help renders re-verified).
#0.3.0 - ::after cancel-id discrimination (user-directed 2026-07-13): the cancelid and info forms' id argument is typed stringstartswith(<prefix>) with the prefix harvested from the RUNNING interpreter at define time (safe create+cancel probe 'after 999999 {}' / 'after cancel $id' - G-054 technique; prefix is after# on 8.6.11 and 9.0.3, hardcoded after#%d in tclTimer.c), substituted into the definition via a build-time %AFTERIDPREFIX% string map (CORRECTED finding: parse-field tstr IS expanded for registered PUNKARGS definitions, but in the argdoc subnamespace when one exists - a variable set in the parent namespace is unresolvable there and the param is left silently literal; build-time substitution sidesteps the defspace subtlety). -typesynopsis id keeps the synopsis rendering as the man page's 'id'. Effect under G-041 form candidacy: 'after cancel <non-id-shaped-word>' resolves cleanly to the cancelscript form matching real semantics (real 'after cancel' with a non-id is a silent script-match no-op), and 'after info <non-id>' is model-rejected where real errors at runtime (parity-true); an id-SHAPED word after cancel remains truthfully ambiguous (cancelid+cancelscript) - real Tcl resolves that junction by id liveness at runtime, which no static type expresses; dead-id over-acceptance on 'after info' recorded as the accepted runtime-liveness boundary. Both ids gain man-page-derived -help text. Parity pins added in tclcoreparity.test (id-shape harvest agreement, cancel discrimination incl the liveness ambiguity, info error-vs-ok parity + accepted dead-id divergence).
#0.2.0 - G-054: 'string is' class choices (and the generated per-class virtual docids) are harvested from the RUNNING interpreter at define time instead of a hand-maintained list - a deliberately invalid probe of the builtin yields the authoritative class set from its error message (8.6: 21 classes, no dict; unreleased 8.7: +dict +unicode; 9.0: +dict, unicode removed), fixing accept/reject drift such as the doc wrongly accepting 'string is dict' under 8.6. Hand-written man-page descriptions apply only to classes the runtime accepts (generic label for unrecognized future classes); static version notes on dict (not in 8.6) and unicode (unreleased 8.7 only). Parity pinned by tclcoreparity.test with expectations derived from the live interpreter (green on 8.6.13, 8.7a6, 9.0.3)

96
src/tests/modules/punk/args/testsuites/args/dynamic.test

@ -0,0 +1,96 @@
package require tcltest
package require punk::args
#@dynamic definition resolution behaviour - added 2026-07-13 (agent, user-reported
#repeated bad-@dynamic warnings from 'i join').
#A definition marked @dynamic whose round-1 tstr output contains no round-2 ${...}
#parameters is a "bad @dynamic tag": the tag buys nothing (display-field ${...} is
#deferred regardless - see the define help's interpolation section). resolve warns -
#and since 2026-07-13 caches the round-1 output as a zero-param unresolved entry, so
#the warning emits ONCE per definition and later resolves skip the re-mask/re-tstr of
#round 1 (the same round-1 freezing legitimately dynamic definitions get).
#Legitimately dynamic definitions are unaffected: round-2 parameters re-substitute on
#every resolve.
namespace eval ::testspace {
namespace import ::tcltest::*
variable common {
set result ""
}
#capture stderr written during script (chan push transform; popped in all paths)
proc capture_stderr {script} {
variable caught ""
chan push stderr {apply {{cmd chan args} {
switch -- $cmd {
initialize {return {initialize finalize write}}
finalize {return}
write {append ::testspace::caught [lindex $args 0]; return ""}
}
}}}
set code [catch {uplevel 1 $script} r ropts]
chan pop stderr
if {$code} {
return -options $ropts $r
}
return $caught
}
test dynamic_bad_tag_warns_once {a @dynamic definition with no round-2 parameters warns once and is served from the round-1 cache thereafter}\
-setup $common -body {
punk::args::define {
@dynamic
@id -id ::testspace::baddyn
@cmd -name testspace::baddyn -help "display-only placeholder ${$display_only_content}"
@values -min 1 -max 1
v -type string
}
set errout [capture_stderr {
punk::args::get_spec ::testspace::baddyn
punk::args::get_spec ::testspace::baddyn
punk::args::parse {hello} withid ::testspace::baddyn
punk::args::parse_status {hello} withid ::testspace::baddyn
}]
lappend result [regexp -all {bad @dynamic tag for id:::testspace::baddyn} $errout]
#the cached round-1 output still parses correctly
set argd [punk::args::parse {world} withid ::testspace::baddyn]
lappend result [dict get $argd values v]
}\
-cleanup {
punk::args::undefine ::testspace::baddyn 1
}\
-result [list 1 world]
test dynamic_legit_round2_stays_live {a legitimately dynamic definition re-substitutes round-2 parameters on every resolve, with no bad-tag warning}\
-setup $common -body {
variable dynchoices {a b}
proc ::testspace::choices_now {} {
variable dynchoices
return $dynchoices
}
namespace eval ::testspace::dynfix {
set DYN_CHOICES {${[::testspace::choices_now]}}
punk::args::define {
@dynamic
@id -id ::testspace::gooddyn
@cmd -name testspace::gooddyn -help "legit dynamic"
@values -min 1 -max 1
v -choices {${$DYN_CHOICES}}
}
}
set errout [capture_stderr {
lappend result [dict get [punk::args::get_spec ::testspace::gooddyn] FORMS _default ARG_INFO v -choices]
set ::testspace::dynchoices {c d}
lappend result [dict get [punk::args::get_spec ::testspace::gooddyn] FORMS _default ARG_INFO v -choices]
}]
lappend result [regexp -all {bad @dynamic tag} $errout]
}\
-cleanup {
namespace delete ::testspace::dynfix
rename ::testspace::choices_now {}
punk::args::undefine ::testspace::gooddyn 1
}\
-result [list {a b} {c d} 0]
}
tcltest::cleanupTests ;#needed to produce test summary line.
Loading…
Cancel
Save