diff --git a/src/bootsupport/modules/punk/console-0.8.0.tm b/src/bootsupport/modules/punk/console-0.8.1.tm similarity index 98% rename from src/bootsupport/modules/punk/console-0.8.0.tm rename to src/bootsupport/modules/punk/console-0.8.1.tm index 446c0a93..28b74ec7 100644 --- a/src/bootsupport/modules/punk/console-0.8.0.tm +++ b/src/bootsupport/modules/punk/console-0.8.1.tm @@ -7,7 +7,7 @@ # (C) 2023 # # @@ Meta Begin -# Application punk::console 0.8.0 +# Application punk::console 0.8.1 # Meta platform tcl # Meta license # @@ Meta End @@ -17,7 +17,7 @@ # doctools header # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ #*** !doctools -#[manpage_begin punkshell_module_punk::console 0 0.8.0] +#[manpage_begin punkshell_module_punk::console 0 0.8.1] #[copyright "2024"] #[titledesc {punk console}] [comment {-- Name section and table of contents description --}] #[moddesc {punk console}] [comment {-- Description at end of page heading --}] @@ -2809,6 +2809,30 @@ namespace eval punk::console { return [dict create columns 80 rows 24] } + lappend PUNKARGS [list { + @id -id ::punk::console::size_result_valid + @cmd -name punk::console::size_result_valid -summary\ + "True if a size-mechanism result is a well-formed {columns rows } dict." + @values -min 1 -max 1 + sizedict -type any -help\ + "Candidate result from a get_size_using_* mechanism." + }] + proc size_result_valid {sizedict} { + #see PUNKARGS id ::punk::console::size_result_valid + #Guard for size_via_query_mechanisms: a mechanism 'success' must be well-formed. + #Mechanisms can otherwise return malformed data without erroring and win the + #cached-mechanism loop silently (the 2026-08-03 tput single-capname regression + #class - and the ANSI mechanisms return {columns {} rows {}} on a query timeout). + if {[catch {dict size $sizedict} pairs] || $pairs != 2} { + return 0 + } + expr { + [dict exists $sizedict columns] && [dict exists $sizedict rows] + && [string is integer -strict [dict get $sizedict columns]] + && [string is integer -strict [dict get $sizedict rows]] + } + } + #ANSI/tput size mechanisms with per-console-pair timing cache. Returns a dict #{columns rows } or an empty dict when size cannot be determined. #Used by the legacy get_size channel path and (as console_size_provider) by the base @@ -2828,7 +2852,10 @@ namespace eval punk::console { #see PUNKARGS id ::punk::console::size_via_query_mechanisms if {[console_fact_get $inoutchannels is_vt52]} { #vt52 doesn't support cursor save/restore or cursor position reports. - if {![catch {get_size_using_tput $inoutchannels} sizedict]} { + if {![catch {get_size_using_sttysize $inoutchannels} sizedict] && [size_result_valid $sizedict]} { + return $sizedict + } + if {![catch {get_size_using_tput $inoutchannels} sizedict] && [size_result_valid $sizedict]} { return $sizedict } return [dict create] @@ -2841,19 +2868,25 @@ namespace eval punk::console { #we will also use the results for our initial return of the size. set successful_mechs [list] set sizedict [dict create] - if {![catch {get_size_using_cursorrestore $inoutchannels} result]} { + if {![catch {get_size_using_cursorrestore $inoutchannels} result] && [size_result_valid $result]} { lappend successful_mechs "cursorrestore" if {![dict size $sizedict]} { set sizedict $result } } - if {![catch {get_size_using_cursormove $inoutchannels} result]} { + if {![catch {get_size_using_cursormove $inoutchannels} result] && [size_result_valid $result]} { lappend successful_mechs "cursormove" if {![dict size $sizedict]} { set sizedict $result } } - if {![catch {get_size_using_tput $inoutchannels} result] } { + if {![catch {get_size_using_sttysize $inoutchannels} result] && [size_result_valid $result]} { + lappend successful_mechs "sttysize" + if {![dict size $sizedict]} { + set sizedict $result + } + } + if {![catch {get_size_using_tput $inoutchannels} result] && [size_result_valid $result]} { lappend successful_mechs "tput" if {![dict size $sizedict]} { set sizedict $result @@ -2879,7 +2912,7 @@ namespace eval punk::console { } foreach mech [dict get $get_size_mechanism $inoutchannels] { - if {![catch {get_size_using_$mech $inoutchannels} sizedict]} { + if {![catch {get_size_using_$mech $inoutchannels} sizedict] && [size_result_valid $sizedict]} { return $sizedict } } @@ -2971,6 +3004,11 @@ namespace eval punk::console { #this mechanism is much faster than ansi cursor movements #REVIEW check if any x-platform anomalies with this method? #can -winsize key exist but contain erroneous info? We will check that we get 2 ints at least + #NOTE (verified 2026-08-03, tcl 9.0.2): unix tty/serial channels do NOT expose -winsize + #(windows console channels do) - so on unix this mechanism currently always falls through. + #If a future tcl adds unix -winsize, be aware a stacked/reflected stdout (e.g the repl's + #shellfilter stack) masks the underlying tty's options - probing the base tty channel + #(or /dev/tty) would be needed for it to be seen under stacked channels. lassign [dict get $outconf -winsize] cols lines if {[string is integer -strict $cols] && [string is integer -strict $lines]} { return [dict create columns $cols rows $lines] @@ -2979,6 +3017,34 @@ namespace eval punk::console { error "chan configure method of getting console size not supported or failed to get valid size info" } + lappend PUNKARGS [list { + @id -id ::punk::console::get_size_using_sttysize + @cmd -name punk::console::get_size_using_sttysize -summary\ + "Console size via 'stty size' on the input channel's tty (single fork) - errors when unavailable or input is not a tty." + @values -min 0 -max 1 + inoutchannels -type list -default {stdin stdout} -optional 1 -help\ + "Canonical {in out} channel pair (internal size mechanism - not spec-form aware)." + }] + proc get_size_using_sttysize {{inoutchannels {stdin stdout}}} { + #see PUNKARGS id ::punk::console::get_size_using_sttysize + #'stty size' emits " " from the tty on its stdin - a single fork with a + #fixed output shape (GNU, busybox and BSD stty all support it - not strictly POSIX). + #Preferred over tput for the exec-based fallback tier: tput multi-capname handling + #varies by implementation (see get_size_using_tput autodetect). Non-tty input makes + #stty error, which correctly drops this mechanism from size_via_query_mechanisms. + set in [lindex $inoutchannels 0] + set sttycmd [auto_execok stty] + if {$sttycmd eq ""} { + error "stty command not found - cannot use stty size method to get console size" + } + set sizeinfo [string trim [exec {*}$sttycmd size <@$in]] + lassign $sizeinfo lines cols + if {![string is integer -strict $lines] || ![string is integer -strict $cols] || $lines < 1 || $cols < 1} { + error "stty size returned '$sizeinfo' - not usable as ' '" + } + return [dict create columns $cols rows $lines] + } + lappend PUNKARGS [list { @id -id ::punk::console::get_size_using_tput @cmd -name punk::console::get_size_using_tput -summary\ @@ -2993,7 +3059,44 @@ namespace eval punk::console { if {$tputcmd eq ""} { error "tput command not found - cannot use tput method to get console size" } - lassign [exec {*}$tputcmd lines cols] lines cols + #tput multi-capname behaviour varies by implementation: ncurses >= 6.1 and the BSD + #tputs emit one value per capname ("66\n266"), but older ncurses treats the second + #capname as an (ignored) parameter of the first and emits only "66" - which used to + #lassign cols to "" here and return 'columns {} rows 66' (2026-08-03 WSL get_size + #regression once the mechanism-timing cache ranked tput first). Autodetect by result + #shape on first use and remember per tput path - capable tputs stay single-fork, + #single-cap-only tputs settle into two forks per call. + variable tput_multicap + if {![info exists tput_multicap]} {set tput_multicap [dict create]} + if {[dict exists $tput_multicap $tputcmd] && ![dict get $tput_multicap $tputcmd]} { + set lines [string trim [exec {*}$tputcmd lines]] + set cols [string trim [exec {*}$tputcmd cols]] + } else { + set outdata [exec {*}$tputcmd lines cols] + set values [list] + foreach v [split $outdata \n] { + set v [string trim $v] + if {$v ne ""} {lappend values $v} + } + switch -- [llength $values] { + 2 { + lassign $values lines cols + dict set tput_multicap $tputcmd 1 + } + 1 { + #single-cap-only tput evaluated 'lines' and ignored 'cols' + dict set tput_multicap $tputcmd 0 + set lines [lindex $values 0] + set cols [string trim [exec {*}$tputcmd cols]] + } + default { + error "tput lines cols returned '$outdata' - unrecognised output shape" + } + } + } + if {![string is integer -strict $lines] || ![string is integer -strict $cols] || $lines < 1 || $cols < 1} { + error "tput returned lines='$lines' cols='$cols' - not usable as positive integers" + } return [dict create columns $cols rows $lines] } @@ -5787,7 +5890,7 @@ namespace eval punk::console::system { # Standalone debug run example (from this directory): # pwsh -nop -nol -f consolemode_server_async.ps1 test1 0 1 # Protocol (one line per named-pipe connection): enableraw | disableraw | ping | exit -# MAINTENANCE: src/modules/punk/console-0.8.0.tm carries this file's text as +# MAINTENANCE: src/modules/punk/console-0.8.1.tm carries this file's text as # punk::console::system::ps_consolemode_script_embedded (the last-resort resolution for kits and # unusual cwds). Keep the two in sync - the console testsuite (psfallback.test) fails when they diverge. ; @@ -6906,7 +7009,7 @@ namespace eval ::punk::args::register { ## Ready package provide punk::console [namespace eval punk::console { variable version - set version 0.8.0 + set version 0.8.1 }] return diff --git a/src/bootsupport/modules/punk/mix/templates-0.2.0.tm b/src/bootsupport/modules/punk/mix/templates-0.2.0.tm index dfcc2357..8b65b3dc 100644 Binary files a/src/bootsupport/modules/punk/mix/templates-0.2.0.tm and b/src/bootsupport/modules/punk/mix/templates-0.2.0.tm differ diff --git a/src/bootsupport/modules/punk/tcltestrun-0.3.1.tm b/src/bootsupport/modules/punk/tcltestrun-0.4.0.tm similarity index 80% rename from src/bootsupport/modules/punk/tcltestrun-0.3.1.tm rename to src/bootsupport/modules/punk/tcltestrun-0.4.0.tm index 78d899c0..8682d2fd 100644 --- a/src/bootsupport/modules/punk/tcltestrun-0.3.1.tm +++ b/src/bootsupport/modules/punk/tcltestrun-0.4.0.tm @@ -8,7 +8,7 @@ # (C) 2026 # # @@ Meta Begin -# Application punk::tcltestrun 0.3.1 +# Application punk::tcltestrun 0.4.0 # Meta platform tcl # Meta license BSD # @@ Meta End @@ -36,7 +36,12 @@ tcl::namespace::eval punk::tcltestrun { "Parse a dict containing stderr and stdout keys into testresult summary data."\ -help\ "Parse a dict containing stderr and stdout keys into testresult summary data. - The dict is expected to be in the format returned by shellrun::runx from execution of a tcltest script" + The dict is expected to be in the format returned by shellrun::runx from execution of a tcltest script. + Intended for streams produced with tcltest -verbose {body pass skip start error line usec} (the + runtests.tcl configuration). Failing tests whose descriptions contain embedded newlines (multi-line + opening banners) are parsed with full fidelity when the stream carries -verbose start events; without + start events such banners are not recognised and parsing degrades to the historic single-line-only + behaviour." @leaders @opts @values -min 1 -max 2 @@ -81,12 +86,63 @@ tcl::namespace::eval punk::tcltestrun { set test_case_pass [dict create] #dict set test_case_pass microseconds 0 set test_case_time [dict create] + #most recently started test name, from "---- start" lines (-verbose start). + #Anchor for multi-line opening-banner recognition (G-161): without start events the + #parser retains its historic single-line-banner-only behaviour. + set current_start_name "" switch -- $what { stdout { foreach ln [split $chunk \n] { set ln_trimright [string trimright $ln] incr i set fail_stage [dict get $test_case_fail stage] + if {$fail_stage eq "banner"} { + #G-161: accumulating the remainder of a multi-line opening banner. + #tcltest emits the opening banner as ONE puts of + # ==== FAILED + #and trims only the description's ENDS, so a description containing + #embedded newlines makes the banner span physical lines: the first line + #starts "==== " (recognised by the anchored entry point below) and + #the final line always ends " FAILED" (the description's last line is + #non-whitespace-terminated, so the space before FAILED is guaranteed). + if {[string match "* FAILED" $ln_trimright]} { + #banner complete - same state as a single-line opener + dict append test_case_fail test_openingtext "\n[string range $ln_trimright 0 end-7]" + dict set test_case_fail stage open + dict unset test_case_fail banner_lines + dict append results out "<$pkg> $ln" \n + continue + } elseif {[string match "==== Contents of test case:*" $ln]} { + #terminator line missed (pathological description content) - treat the + #banner as complete and take the open->contents transition so the + #parser cannot wedge waiting for a line that already went past + dict set test_case_fail stage contents + dict unset test_case_fail banner_lines + dict append results out "<$pkg> $ln" \n + continue + } elseif {[string match "++++ * took *" $ln_trimright] + || [string match "++++ * PASSED" $ln_trimright] + || [string match "++++ * SKIPPED: *" $ln_trimright] + || [string match "---- * start" $ln_trimright] + || [string match "*:*Total*Passed*Skipped*Failed*" $ln] + || [dict get $test_case_fail banner_lines] >= 100} { + #a recognisable event line (or an implausibly long banner) while + #accumulating: this was not a banner after all - abandon capture and + #reprocess the current line through normal handling below. + #Already-buffered lines were ordinary output; they remain in 'out' + #where they were appended as they streamed. + dict set test_case_fail stage "" + dict set test_case_fail test_openingtext "" + dict unset test_case_fail banner_lines + set fail_stage "" + #deliberate fall-through - no continue + } else { + dict append test_case_fail test_openingtext "\n$ln" + dict incr test_case_fail banner_lines + dict append results out "<$pkg> $ln" \n + continue + } + } if {$fail_stage eq ""} { #not within a test case failure section, so we can parse summary lines and other output normally. @@ -124,6 +180,17 @@ tcl::namespace::eval punk::tcltestrun { continue } + # ---- start (-verbose start; the runner always enables it) + # Track the most recently started test: the anchor that lets a multi-line + # opening banner be recognised by name prefix (G-161). The name may itself + # contain spaces and even the word "start", so anchor on the LAST " start". + if {[string match "---- * start" $ln_trimright]} { + set start_pos [string last " start" $ln_trimright] + set current_start_name [string range $ln_trimright 5 [expr {$start_pos - 1}]] + dict append results out "<$pkg> $ln" \n + continue + } + if {[string match "Tests ended at*" $ln]} { #review - what outputs this? #puts stdout "<$pkg> $ln" @@ -153,6 +220,19 @@ tcl::namespace::eval punk::tcltestrun { dict set test_case_fail stage open set line_inner [string range $ln 5 end-7] ;#trim leading ==== and trailing FAILED dict set test_case_fail test_openingtext $line_inner + } elseif {$current_start_name ne "" && [string match "==== *" $ln] + && [string equal -length [string length "$current_start_name "] "$current_start_name " [string range $ln 5 end]]} { + #G-161: entry point to a MULTI-LINE opening banner - a line starting + #"==== " that does not end " FAILED" is the first + #physical line of a banner whose description contains embedded newlines + #(single tcltest puts; see the banner stage above for the terminator). + #Anchoring on the current started test's name (exact prefix compare, no + #glob) means ordinary output cannot open banner capture, and streams + #without -verbose start events never take this branch - their parsing + #is unchanged. + dict set test_case_fail stage banner + dict set test_case_fail test_openingtext [string range $ln 5 end] + dict set test_case_fail banner_lines 1 } dict append results out "<$pkg> $ln" \n @@ -232,7 +312,20 @@ tcl::namespace::eval punk::tcltestrun { # Result capture for FAILED (non-error) tests: grab "---- Result was:" and "---- Result should have been" blocks. if {$fail_stage eq "contents"} { if {[dict exists $test_case_fail result_stage] && [dict get $test_case_fail result_stage] ne ""} { - if {[string match "---- *" $ln] || [string match "==== * FAILED" $ln_trimright] || [string match "++++ *" $ln]} { + if {[string match "---- Result should have been*" $ln]} { + # Direct transition result_was -> result_expected. The two blocks are + # adjacent single puts, so this opener always arrives while the + # result_was capture is still ACTIVE. Before G-161 the line merely + # reset the active capture and the fall-through skipped the opener + # elseif below: result_expected was never captured (absent from every + # report) and the expected-value lines leaked into test_body. + dict set test_case_fail result_stage "result_expected" + if {![dict exists $test_case_fail result_expected]} { + dict set test_case_fail result_expected "" + } + dict append results out "<$pkg> $ln" \n + continue + } elseif {[string match "---- *" $ln] || [string match "==== * FAILED" $ln_trimright] || [string match "++++ *" $ln]} { dict set test_case_fail result_stage "" # fall through to normal handling below (do not continue) } else { @@ -304,6 +397,7 @@ tcl::namespace::eval punk::tcltestrun { dict unset test_case_fail result_was dict unset test_case_fail result_expected dict unset test_case_fail result_stage + dict unset test_case_fail banner_lines dict append results out "<$pkg> $ln" \n continue ;#skip to next line. @@ -543,7 +637,7 @@ namespace eval ::punk::args::register { package provide punk::tcltestrun [tcl::namespace::eval punk::tcltestrun { variable pkg punk::tcltestrun variable version - set version 0.3.1 + set version 0.4.0 }] return diff --git a/src/bootsupport/modules/shellfilter-0.2.4.tm b/src/bootsupport/modules/shellfilter-0.2.5.tm similarity index 98% rename from src/bootsupport/modules/shellfilter-0.2.4.tm rename to src/bootsupport/modules/shellfilter-0.2.5.tm index 0bf17fd8..bd5db3d8 100644 --- a/src/bootsupport/modules/shellfilter-0.2.4.tm +++ b/src/bootsupport/modules/shellfilter-0.2.5.tm @@ -419,7 +419,10 @@ namespace eval shellfilter::chan { } method initialize {transform_handle mode} { #return [list initialize read drain write flush clear finalize] - return [list initialize write flush clear finalize] + #'clear' deliberately NOT declared: when a transform declares it, the core + #delivers a 'clear' op before EVERY write (tclIORTrans.c ReflectOutput), not + #just on seek - see goals/archive/G-145-piped-usage-ansi-remnants.md. + return [list initialize write flush finalize] } method finalize {transform_handle} { #Note that an error in the finalize can stop 'chan pop' from running properly. @@ -430,8 +433,15 @@ namespace eval shellfilter::chan { # must be present but we ignore it because we do not # post any events } + #G-145 defect class: 'clear' must NOT discard o_encbuf (held partial multi-byte + #character). With clear declared, the core calls it before every write; dropping + #the carry leaves the next chunk starting with orphan continuation bytes, making + #whole chunks unconvertible - they accumulate in o_encbuf and successive clears + #discard them, eating contiguous ranges of multibyte-dense output (the 2026-08-03 + #unix repl result-echo corruption). Kept as a state-preserving no-op in case + #'clear' is ever re-declared; this write-only transform has no read-side state + #(the documented scope of 'clear'). method clear {transform_handle} { - set o_encbuf "" return } #method drain {transform_handle} { @@ -471,16 +481,22 @@ namespace eval shellfilter::chan { # return $clear #} method flush {transform_handle} { - set clear $o_buffered$o_encbuf - if {[catch {tcl::encoding::convertfrom $o_enc $clear} stringdata]} { - #if we can't convert the buffer contents to a string - does it make sense to emit the raw bytes? - # - probably not. - #REVIEW? + #this class holds only o_encbuf (raw bytes of a trailing incomplete + #multi-byte character) - there is no o_buffered ansi-carry here (that + #belongs to the ansiwrap/ansistrip style transforms). + #An incomplete char is not decodable on its own: hold it for the next write + #rather than emitting garbage or discarding (G-145: dropping held stream + #state corrupts content split across write chunks). finalize dropping it at + #true end of stream is acceptable. + if {$o_encbuf eq ""} { + return "" + } + if {[catch {tcl::encoding::convertfrom $o_enc $o_encbuf} stringdata]} { return "" } - set o_buffered "" set o_encbuf "" - return $stringdata + puts -nonewline $o_localchan $stringdata + return [tcl::encoding::convertto $o_enc $stringdata] } method write {transform_handle bytes} { #set logdata [tcl::encoding::convertfrom $o_enc $bytes] @@ -570,16 +586,19 @@ namespace eval shellfilter::chan { # return $clear #} method flush {transform_handle} { - set clear $o_buffered$o_encbuf - if {[catch {tcl::encoding::convertfrom $o_enc $clear} stringdata]} { - #if we can't convert the buffer contents to a string - does it make sense to emit the raw bytes? - # - probably not. - #REVIEW? + #only o_encbuf exists in this class (no o_buffered ansi-carry as in ansiwrap). + #An incomplete multi-byte char is not decodable on its own: hold it for the + #next write rather than discarding (G-145: dropping held stream state + #corrupts content split across write chunks). + if {$o_encbuf eq ""} { + return "" + } + if {[catch {tcl::encoding::convertfrom $o_enc $o_encbuf} stringdata]} { return "" } - set o_buffered "" set o_encbuf "" - return $stringdata + ::shellfilter::log::write $o_logsource $stringdata + return [tcl::encoding::convertto $o_enc $stringdata] } method write {ch bytes} { #set logdata [tcl::encoding::convertfrom $o_enc $bytes] @@ -668,16 +687,19 @@ namespace eval shellfilter::chan { # return #} method flush {transform_handle} { - set clear $o_buffered$o_encbuf - if {[catch {tcl::encoding::convertfrom $o_enc $clear} stringdata]} { - #if we have data in the buffer that we haven't been able to convert to a string - #- then we probably have some kind of encoding mismatch. Is it safer to discard it than to emit garbage chars to the log? - #REVIEW. - we are writing the raw bytes to the log here because we can't convert them to a string. - #This may be useful for debugging issues, but it may also result in garbage data in the log. - ::shellfilter::log::write $o_logsource $o_encbuf + #only o_encbuf exists in this class (no o_buffered ansi-carry as in ansiwrap). + #logonly emits nothing downstream. An incomplete multi-byte char is not + #decodable on its own: hold it for the next write rather than discarding + #(G-145: dropping held stream state corrupts content split across write + #chunks); if it decodes anyway (e.g after an encoding change), log it now. + if {$o_encbuf eq ""} { + return "" + } + if {![catch {tcl::encoding::convertfrom $o_enc $o_encbuf} stringdata]} { + ::shellfilter::log::write $o_logsource $stringdata set o_encbuf "" } - return + return "" } method write {transform_handle bytes} { #set logdata [encoding convertfrom $o_enc $bytes] @@ -3838,5 +3860,5 @@ namespace eval shellfilter { package provide shellfilter [namespace eval shellfilter { variable version - set version 0.2.4 + set version 0.2.5 }]