From b35ea12896b77951f6ee00f2e5cb680cbae3b86d Mon Sep 17 00:00:00 2001 From: Julian Noble Date: Mon, 13 Jul 2026 03:50:00 +1000 Subject: [PATCH] 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 --- CHANGELOG.md | 4 + punkproject.toml | 2 +- src/modules/punk/args-999999.0a1.0.tm | 23 +++-- src/modules/punk/args-buildversion.txt | 3 +- .../args/moduledoc/tclcore-999999.0a1.0.tm | 4 +- .../args/moduledoc/tclcore-buildversion.txt | 3 +- .../punk/args/testsuites/args/dynamic.test | 96 +++++++++++++++++++ 7 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 src/tests/modules/punk/args/testsuites/args/dynamic.test diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d5ffa7a..3f5e571e 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/punkproject.toml b/punkproject.toml index 18e4dbe7..73da0da9 100644 --- a/punkproject.toml +++ b/punkproject.toml @@ -1,4 +1,4 @@ [project] name = "punkshell" -version = "0.12.18" +version = "0.12.19" license = "BSD-2-Clause" diff --git a/src/modules/punk/args-999999.0a1.0.tm b/src/modules/punk/args-999999.0a1.0.tm index 779f5f41..2d0a217d 100644 --- a/src/modules/punk/args-999999.0a1.0.tm +++ b/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] diff --git a/src/modules/punk/args-buildversion.txt b/src/modules/punk/args-buildversion.txt index e057ae16..44a5f015 100644 --- a/src/modules/punk/args-buildversion.txt +++ b/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). diff --git a/src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm b/src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm index 5fd219b4..ac93f038 100644 --- a/src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm +++ b/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 diff --git a/src/modules/punk/args/moduledoc/tclcore-buildversion.txt b/src/modules/punk/args/moduledoc/tclcore-buildversion.txt index a8679934..0962b95f 100644 --- a/src/modules/punk/args/moduledoc/tclcore-buildversion.txt +++ b/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() 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 ' 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 ' 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) diff --git a/src/tests/modules/punk/args/testsuites/args/dynamic.test b/src/tests/modules/punk/args/testsuites/args/dynamic.test new file mode 100644 index 00000000..db073022 --- /dev/null +++ b/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.