From e03ce1d64a56d271a7cb0fbb1828ae40869a4bda Mon Sep 17 00:00:00 2001 From: Julian Noble Date: Sat, 25 Jul 2026 15:47:04 +1000 Subject: [PATCH] G-113: make.tcl terminal-aware colour policy (tty probe, force overrides, zero-ESC piped) Startup policy: NO_COLOR > PUNK_FORCE_COLOR/FORCE_COLOR > stdout -winsize tty probe (Tcl 8.7+/9); Tcl 8.6 fails OFF. Piped/unknown non-forced runs push an ansistrip transform on stdout+stderr - zero ESC bytes from every emitter incl. module-side (punk::ansi's colour_disabled deliberately keeps non-colour effects; design finding recorded in the goal file). shell pops the transform. sgr_cache cleared for punk-exe-hosted pre-loaded interps per the punk::ansi contract. define_global_ansi fully gated; raw-SGR literals routed through new ::punkboot::sgr gate (fixes a malformed \x1b\31m). check reports the policy. make.tcl converted CRLF->LF. Verified: tclsh90 + punk91 kit piped zero-ESC (check/help/workflow/modules); force overrides re-colour piped output; NO_COLOR always suppresses colour; 8.6 tcl86-plain; real Windows console mode=tty (hidden-console evidence); WSL pty mode=tty vs piped-plain (tclkit-902-Linux64). New suite punkexe/maketclcolour.test 3/3. src/AGENTS.md bullet revised; punkshell 0.22.2. Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com --- CHANGELOG.md | 11 ++ goals/G-112-maketcl-subcommand-rename.md | 3 +- goals/G-113-maketcl-tty-aware-colour.md | 40 ++++ punkproject.toml | 2 +- src/AGENTS.md | 2 +- src/make.tcl | 180 ++++++++++++++++-- .../vendor/punk/project-0.1/src/make.tcl | 180 ++++++++++++++++-- .../vendor/punk/basic/src/make.tcl | 180 ++++++++++++++++-- .../vendor/punk/project-0.1/src/make.tcl | 180 ++++++++++++++++-- .../testsuites/punkexe/maketclcolour.test | 150 +++++++++++++++ 10 files changed, 873 insertions(+), 55 deletions(-) create mode 100644 src/tests/shell/testsuites/punkexe/maketclcolour.test diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d264dbb..85773191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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.22.2] - 2026-07-25 + +- make.tcl colour output is terminal-aware (G-113): piped/redirected runs produce fully + ESC-free output automatically (ansistrip channel transform on stdout+stderr - covers + module-side emitters too), interactive terminal runs keep colour (stdout `-winsize` tty + probe on Tcl 8.7+/9; verified on a real Windows console and a WSL pty). NO_COLOR always + suppresses colour; PUNK_FORCE_COLOR/FORCE_COLOR re-enable ANSI on piped output; Tcl 8.6 + (no probe) fails OFF. `make.tcl check` reports the active policy; `make.tcl shell` pops + the transform for the repl. New characterization suite + `src/tests/shell/testsuites/punkexe/maketclcolour.test`. make.tcl converted to LF endings. + ## [0.22.1] - 2026-07-25 - punk::libunknown 0.2.2 (G-120): the package-unknown static-builtin shortcircuit no longer diff --git a/goals/G-112-maketcl-subcommand-rename.md b/goals/G-112-maketcl-subcommand-rename.md index 5492a8fc..f2c7e2c9 100644 --- a/goals/G-112-maketcl-subcommand-rename.md +++ b/goals/G-112-maketcl-subcommand-rename.md @@ -62,7 +62,8 @@ act rather than an input folder. RESOLVED at activation 2026-07-25 (user-directed dispositions): - `vfslibs` disposition: folds into bake (binary libs are build outputs, per the - G-004/G-103 direction) - the provenance-argument alternative (promote under + G-004 direction and archived G-103, achieved 2026-07-22 - see + goals/archive/G-103-runtime-kit-family.md) - the provenance-argument alternative (promote under `vfscommonupdate`) is not taken. Whether `vfslibs` survives as a standalone narrowing command is decided in-session during the rename work and recorded in Progress. diff --git a/goals/G-113-maketcl-tty-aware-colour.md b/goals/G-113-maketcl-tty-aware-colour.md index 0a63a9de..c62bdf16 100644 --- a/goals/G-113-maketcl-tty-aware-colour.md +++ b/goals/G-113-maketcl-tty-aware-colour.md @@ -23,6 +23,46 @@ Agents drive `tclsh src/make.tcl ...` with captured/piped output on every build- - Post-hoc SGR stripping in harnesses - rejected: pushes the burden onto every consumer. - Per-channel colour policy (stderr coloured when it alone is a tty) - more faithful but more state; default is the stdout-keyed single switch, settled in the work. +## Progress + +- 2026-07-25 implementation landed (make.tcl + suite + docs; punkshell 0.22.2): + startup policy block (precedence NO_COLOR > PUNK_FORCE_COLOR/FORCE_COLOR > + stdout `-winsize` tty probe; modes tty|forced|nocolor|piped-plain|tcl86-plain, + reported by `make.tcl check` and env PUNKBOOT_COLOUR_DEBUG=1), ansistrip + write transform pushed on stdout+stderr for piped/8.6-unknown non-forced runs + (zero-ESC guarantee covering module-side emitters such as punkcheck + summaries; the shell subcommand pops it - repl colour stays the shell's + concern), punk::ansi sgr_cache cleared per that module's contract for + punk-exe-hosted pre-loaded interps, define_global_ansi has_ansi branch fully + gated (A() palette all-empty when colour disabled), raw-SGR literal sites + routed through the new ::punkboot::sgr gate (incl. one malformed \x1b\31m + literal), make.tcl converted CRLF->LF per repo preference. + DESIGN FINDING recorded: punk::ansi's colour_disabled deliberately strips + COLOUR codes only (non-colour effects like bold survive; the state-changer + must clear the sgr cache per the in-module contract). The zero-ESC piped + contract therefore rides the make.tcl boundary transform, not + colour_disabled; NO_COLOR on a real terminal keeps the module's semantics. +- Verification evidence 2026-07-25: + piped (native tclsh90 9.0.3): check/help/workflow/modules all exit 0 with + zero ESC bytes; NO_COLOR=1 zero ESC; FORCE_COLOR=1 / PUNK_FORCE_COLOR=1 emit + colour SGR on piped output (80 colour lines in help); NO_COLOR+FORCE emits + effects but zero colour codes (NO_COLOR wins for colour); FORCE_COLOR=0 + treated as unset. punk91 kit (script src/make.tcl): check/help/workflow exit + 0, zero ESC. Tcl 8.6 (native bawt tclsh86): mode=tcl86-plain, zero ESC; + FORCE re-enables ANSI. Windows console (hidden-console Start-Process recipe, + real console handles): stdout -winsize probe succeeds ({120 30}), policy + reports mode=tty colour_disabled=0 ansistrip=0. Unix pty (WSL native-fs + staging, tclkit-902-Linux64 under script(1)): mode=tty colour_disabled=0; + piped control run in the same staging: mode=piped-plain ansistrip=1. + Suite: shell/testsuites/punkexe/maketclcolour.test 3/3 pass (punk902z). + src/AGENTS.md interim NO_COLOR bullet replaced with implemented-behaviour + guidance. Remaining manual item: human visual confirmation of colour + appearance on live terminals (the mechanical decision + rendering chain is + fully evidenced above). +- Remains for acceptance: none beyond the recorded manual-visual note; flip is + deferred to the make.tcl-window acceptance pass (with G-112/G-104) so the + layout make.tcl copies sync once for the whole window. + ## Notes - Activation freshness 2026-07-25: goals_xref.tcl score G-113 re-run at the diff --git a/punkproject.toml b/punkproject.toml index e2d08578..ba156baf 100644 --- a/punkproject.toml +++ b/punkproject.toml @@ -1,4 +1,4 @@ [project] name = "punkshell" -version = "0.22.1" +version = "0.22.2" license = "BSD-2-Clause" diff --git a/src/AGENTS.md b/src/AGENTS.md index 88a86f8e..5aaa6505 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -63,7 +63,7 @@ Recovery after a wrong path guess: - Use `tclsh src/make.tcl modules` to build just the module packages. - Use `tclsh src/make.tcl libs` to build just the library packages. - Use `tclsh src/make.tcl packages` to build both modules and libraries. -- Agent runs of `tclsh src/make.tcl ...` capture/pipe output: set `NO_COLOR=1` in the environment so captured output carries no ANSI SGR sequences (honoured from startup). Interactive terminal runs keep colour by default. G-113 tracks making this automatic via tty detection - revise this bullet when it lands. +- make.tcl colour is terminal-aware (G-113): piped/redirected runs (agent harnesses, CI, log capture) automatically produce fully ESC-free output with no caller action required - an ansistrip channel transform on stdout+stderr guarantees zero ESC bytes for every emitter, including module-side ones (punkcheck summaries, punk::args tables). Interactive terminal runs keep colour (stdout tty probe via the `-winsize` channel option, Tcl 8.7+/9). Precedence: `NO_COLOR` (any value) always suppresses colour; `PUNK_FORCE_COLOR`/`FORCE_COLOR` (value other than 0/false/no/off) re-enables ANSI on piped output; otherwise the probe decides. Tcl 8.6 has no boot-phase probe - colour fails OFF there (use the force vars for 8.6 interactive colour). stderr follows the stdout decision; `make.tcl shell` pops the transform (repl colour is the shell's own concern). `tclsh src/make.tcl check` reports the active policy (`colour policy (G-113): mode=...` line; modes tty|forced|nocolor|piped-plain|tcl86-plain). Pinned by `src/tests/shell/testsuites/punkexe/maketclcolour.test`. Agents no longer need to set NO_COLOR for captured runs (setting it remains harmless). - Use `tclsh src/make.tcl vendorupdate` to refresh vendormodules from config. It warns (non-fatal) for each source project whose fossil/git checkout is dirty - vendored artifacts built from a dirty tree have no committed provenance; commit in the source project to clear the warning (enforcement policy tracked by goal G-026). - All build/promotion commands (`project`, `packages`, `modules`, `libs`, `vfs`, `vfslibs`, `bin`, `bootsupport`, `vfscommonupdate`) warn similarly when this project's own `src/` has uncommitted fossil/git changes (dirt outside `src/` is ignored). Warn-only by default; pass `-dirty-abort` to make the check aborting. For evaluating uncommitted source without a build, prefer ` src` / ` src shell`. The check is `punkboot::utils::vcs_dirty_warnings` (optional scope argument) loaded guardedly from bootsupport - if the snapshot is stale/missing the check degrades to a skip notice (but `-dirty-abort` then aborts rather than silently losing the requested strictness). - Provenance warnings (dirty-src gate + vendorupdate source-project check) print with a plain column-0 `PROVENANCE-WARNING:` token (grep for it in captured build output) and are recapped at the end of the run via make.tcl's wrapped `::exit`. Interactive terminal runs get a 3-second ctrl-c grace countdown before a dirty build proceeds; piped/agent runs skip it. `tclsh src/make.tcl check` reports the current src provenance status and what the build commands would do. diff --git a/src/make.tcl b/src/make.tcl index 36c83222..c9bd881b 100644 --- a/src/make.tcl +++ b/src/make.tcl @@ -2,8 +2,140 @@ # # punkboot - make any tclkits and modules in /src folders and place them and associated data files/scripts in the parent folder of src. #e.g in 'bin' and 'modules' folders at same level as 'src' folder. -if {[info exists ::env(NO_COLOR)]} { - namespace eval ::punk::console {variable colour_disabled 1} +# --------------------------------------------------------------------------- +# Terminal-aware colour/ANSI policy (G-113) - evaluated before any output. +# Precedence: +# 1. NO_COLOR set (any value) -> colour OFF (always wins; punk::ansi +# NO_COLOR semantics: colour codes stripped, non-colour effects like bold +# may remain when output goes to a terminal) +# 2. PUNK_FORCE_COLOR / FORCE_COLOR set -> ANSI ON even when piped +# (a value of 0/false/no/off is treated as not-set, per ecosystem convention) +# 3. stdout tty probe -> colour ON only when stdout is a +# terminal channel (-winsize option present - Tcl 8.7+/9 terminal channels +# only, mirroring the stdin_is_interactive -inputmode precedent) +# Piped/redirected stdout (probe fails, no force) additionally pushes a strip +# transform on stdout+stderr so the output is fully ESC-free - machine +# consumers get plain text no matter which emitter produced it (punk::ansi's +# colour_disabled contract deliberately retains non-colour SGR effects, and +# module-side emitters like punkcheck summaries follow that contract - the +# transform is the single choke point that guarantees zero ESC bytes). +# Tcl 8.6 has no boot-phase tty probe (no -winsize/-inputmode anywhere): the +# fail direction is plain/OFF (safe for piped/captured agent runs - unlike +# stdin's assume-interactive; use the force env vars for 8.6 interactive colour). +# stderr follows the same single stdout-keyed decision. +# The colour switch is ::punk::console::colour_disabled; when punk::ansi is +# already loaded (punk-exe-hosted runs) its sgr cache is cleared per that +# module's contract ("whatever function disables or re-enables colour should +# have made a call to punk::ansi::sgr_cache -action clear"). +# ::punkboot::colour_mode records the decision: one of +# tty | forced | nocolor | piped-plain | tcl86-plain +# --------------------------------------------------------------------------- +namespace eval ::punkboot { + variable colour_mode "" +} +namespace eval ::punkboot::ansistrip { + #Write-side channel transform stripping all ANSI escape sequences (CSI incl. + #SGR, OSC with BEL/ST terminators, and two-char ESC sequences). Handles + #sequences split across write chunks via a per-channel carry buffer; an + #unterminated sequence held at flush/finalize time is dropped (it is an + #incomplete ANSI sequence - dropping it IS stripping it). + variable carry [dict create] + variable re_complete {\x1b(?:\[[0-9;:<=>?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\^_])} + proc strip {chanid data} { + variable carry + variable re_complete + if {[dict exists $carry $chanid]} { + set data "[dict get $carry $chanid]$data" + dict unset carry $chanid + } + #hold back a trailing incomplete escape sequence (conservative: from the + #last ESC onward, if it is not a complete sequence it may be a prefix of + #one still being written) + set idx [string last \x1b $data] + if {$idx >= 0} { + set tail [string range $data $idx end] + if {![regexp -- "^${re_complete}" $tail]} { + dict set carry $chanid $tail + set data [string range $data 0 $idx-1] + } + } + regsub -all -- $re_complete $data {} data + return $data + } + proc transchan {op chanid args} { + variable carry + switch -- $op { + initialize { + return [list initialize finalize write flush clear] + } + write { + return [strip $chanid [lindex $args 0]] + } + flush { + return "" + } + clear - + finalize { + dict unset carry $chanid + return + } + } + } +} +apply {{} { + set force 0 + foreach fvar {PUNK_FORCE_COLOR FORCE_COLOR} { + if {[info exists ::env($fvar)] && $::env($fvar) ni {0 false FALSE no NO off OFF}} { + set force 1 + break + } + } + set stdout_is_tty [expr {![catch {chan configure stdout -winsize}]}] + if {[info exists ::env(NO_COLOR)]} { + namespace eval ::punk::console {variable colour_disabled 1} + set ::punkboot::colour_mode nocolor + } elseif {$force} { + namespace eval ::punk::console {variable colour_disabled 0} + set ::punkboot::colour_mode forced + } elseif {$stdout_is_tty} { + namespace eval ::punk::console {variable colour_disabled 0} + set ::punkboot::colour_mode tty + } else { + namespace eval ::punk::console {variable colour_disabled 1} + if {[package vsatisfies [package provide Tcl] 8.7-]} { + set ::punkboot::colour_mode piped-plain + } else { + set ::punkboot::colour_mode tcl86-plain + } + } + #zero-ESC guarantee for piped/unknown output unless the caller forced ANSI on. + #(NO_COLOR keeps punk::ansi's documented colour-only-stripping semantics on a + #terminal; a NO_COLOR run with piped stdout still gets the full strip.) + if {!$force && !$stdout_is_tty} { + chan push stdout ::punkboot::ansistrip::transchan + chan push stderr ::punkboot::ansistrip::transchan + set ::punkboot::ansistrip_pushed 1 + } else { + set ::punkboot::ansistrip_pushed 0 + } + #punk-exe-hosted runs arrive with punk::ansi pre-loaded and a warm sgr cache + #generated under the kit's own colour state - honour the module's contract. + if {[package provide punk::ansi] ne ""} { + catch {punk::ansi::sgr_cache -action clear} + } + if {[info exists ::env(PUNKBOOT_COLOUR_DEBUG)]} { + puts stderr "punkboot colour policy: mode=$::punkboot::colour_mode colour_disabled=$::punk::console::colour_disabled ansistrip=$::punkboot::ansistrip_pushed" + } +}} +# Colour-policy gate for raw-SGR emission sites that can run before +# define_global_ansi / punk::ansi are available (early package-load +# diagnostics and kit-build warnings). Returns the SGR sequence, or an empty +# string when colour is disabled. Empty/omitted params give the reset. +proc ::punkboot::sgr {{sgrparams ""}} { + if {[info exists ::punk::console::colour_disabled] && $::punk::console::colour_disabled} { + return "" + } + return "\x1b\[${sgrparams}m" } set hashline "# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ###" puts $hashline @@ -861,11 +993,11 @@ proc ::punkboot::check_package_availability {args} { #package missing - or exists - but failing to initialise if {!$::opt_quiet} { set parent_path [lrange $::test::pkg_stack 0 end-1] - puts stderr "\x1b\[32m $pkgname versions: $versions error: $result\x1b\[m" + puts stderr "[punkboot::sgr 32] $pkgname versions: $versions error: $result[punkboot::sgr]" set parent_path [join $parent_path " -> "] puts stderr "pkg requirements: $parent_path" puts stderr "error during : '$args'" - puts stderr " \x1b\[93m$result\x1b\[m" + puts stderr " [punkboot::sgr 93]$result[punkboot::sgr]" } #the failed package may still exist - so we could check 'package files' and 'package ifneeded' here too - REVIEW @@ -1353,12 +1485,24 @@ proc ::punkboot::define_global_ansi {} { namespace eval ::punkboot { namespace import ::punk::ansi::a+ ::punk::ansi::a } - set A(RST) \x1b\[m - set A(HIGHLIGHT) [a+ brightyellow] - set A(BWHITE) [a+ brightwhite] - set A(OK) [a+ web-lawngreen] ;#brightgreen - set A(BAD) [a+ web-orange] - set A(ERR) [a+ web-indianred] ;#easier on the eyes than standard red on some screens + if {[info exists ::punk::console::colour_disabled] && $::punk::console::colour_disabled} { + #G-113: the A() palette is colour-only highlighting - when colour is + #disabled every entry (including RST) is empty rather than relying on + #a+/a colour stripping (a bare reset would survive as a non-colour code). + set A(RST) "" + set A(HIGHLIGHT) "" + set A(BWHITE) "" + set A(OK) "" + set A(BAD) "" + set A(ERR) "" + } else { + set A(RST) [a] + set A(HIGHLIGHT) [a+ brightyellow] + set A(BWHITE) [a+ brightwhite] + set A(OK) [a+ web-lawngreen] ;#brightgreen + set A(BAD) [a+ web-orange] + set A(ERR) [a+ web-indianred] ;#easier on the eyes than standard red on some screens + } } } proc ::punkboot::punkboot_gethelp {args} { @@ -2277,6 +2421,9 @@ if {$::punkboot::command in {project packages modules libs vfs vfslibs bin boots if {$::punkboot::command eq "check"} { set sep [string repeat - 75] puts stdout $sep + puts stdout "colour policy (G-113): mode=$::punkboot::colour_mode colour_disabled=$::punk::console::colour_disabled ansistrip=$::punkboot::ansistrip_pushed" + puts stdout " (precedence: NO_COLOR > PUNK_FORCE_COLOR/FORCE_COLOR > stdout tty probe; piped/8.6-unknown output is fully ESC-stripped unless forced)" + puts stdout $sep puts stdout "module/library checks - paths from bootsupport only" puts stdout $sep puts stdout "- tcl::tm::list" @@ -2624,6 +2771,13 @@ if {$::punkboot::command eq "workflow"} { } if {$::punkboot::command eq "shell"} { + #G-113: the ansistrip transform serves make.tcl's own output only - the repl's + #colour behaviour is the shell's own concern (colour on/off + NO_COLOR there). + if {[info exists ::punkboot::ansistrip_pushed] && $::punkboot::ansistrip_pushed} { + catch {chan pop stdout} + catch {chan pop stderr} + set ::punkboot::ansistrip_pushed 0 + } package require struct::list package require punk package require punk::repl @@ -4008,7 +4162,7 @@ proc merge_over {sourcedir targetdir {depth 0}} { set target [file join $targetdir $relpath] if {[file type $actualsource] eq "file"} { #fauxlink linktarget (source data) is a file - puts -nonewline stdout "\x1b\[32m\x1b\[m" + puts -nonewline stdout "[punkboot::sgr 32][punkboot::sgr]" puts "file copy -force $actualsource $target" file copy -force $actualsource $target } else { @@ -4383,7 +4537,7 @@ foreach vfstail $vfs_tails { #the input building_runtime wasn't mountable as a zip - so presumably a plain executable #runtime executable possibly with kit/cookfs etc attached? #If not - init.tcl probably won't be found? should we even proceed ?? - puts stderr "\x1b\[31mWARNING the runtime was not mountable as a zip - which means tcl_library was not extracted - the executable may not work with zipfs attached!\x1b\[m" + puts stderr "[punkboot::sgr 31]WARNING the runtime was not mountable as a zip - which means tcl_library was not extracted - the executable may not work with zipfs attached![punkboot::sgr]" file copy -force $building_runtime $raw_runtime } } else { @@ -4494,7 +4648,7 @@ foreach vfstail $vfs_tails { if {!$extraction_done} { #TODO: if not extracted - use a default tcl_library for patchlevel and platform? puts stderr "--------------------------------------------" - puts stderr "\x1b\31mWARNING: No extraction done from runtime $runtime_fullname\x1b\[m" + puts stderr "[punkboot::sgr 31]WARNING: No extraction done from runtime $runtime_fullname[punkboot::sgr]" puts stderr "If no init.tcl provided in the vfs at the proper location (containing init.tcl) - the resulting kit will probably not initialise properly!" puts stderr "--------------------------------------------" file mkdir $targetvfs diff --git a/src/modules/punk/mix/#modpod-templates-999999.0a1.0/templates/project_layouts/vendor/punk/project-0.1/src/make.tcl b/src/modules/punk/mix/#modpod-templates-999999.0a1.0/templates/project_layouts/vendor/punk/project-0.1/src/make.tcl index 36c83222..c9bd881b 100644 --- a/src/modules/punk/mix/#modpod-templates-999999.0a1.0/templates/project_layouts/vendor/punk/project-0.1/src/make.tcl +++ b/src/modules/punk/mix/#modpod-templates-999999.0a1.0/templates/project_layouts/vendor/punk/project-0.1/src/make.tcl @@ -2,8 +2,140 @@ # # punkboot - make any tclkits and modules in /src folders and place them and associated data files/scripts in the parent folder of src. #e.g in 'bin' and 'modules' folders at same level as 'src' folder. -if {[info exists ::env(NO_COLOR)]} { - namespace eval ::punk::console {variable colour_disabled 1} +# --------------------------------------------------------------------------- +# Terminal-aware colour/ANSI policy (G-113) - evaluated before any output. +# Precedence: +# 1. NO_COLOR set (any value) -> colour OFF (always wins; punk::ansi +# NO_COLOR semantics: colour codes stripped, non-colour effects like bold +# may remain when output goes to a terminal) +# 2. PUNK_FORCE_COLOR / FORCE_COLOR set -> ANSI ON even when piped +# (a value of 0/false/no/off is treated as not-set, per ecosystem convention) +# 3. stdout tty probe -> colour ON only when stdout is a +# terminal channel (-winsize option present - Tcl 8.7+/9 terminal channels +# only, mirroring the stdin_is_interactive -inputmode precedent) +# Piped/redirected stdout (probe fails, no force) additionally pushes a strip +# transform on stdout+stderr so the output is fully ESC-free - machine +# consumers get plain text no matter which emitter produced it (punk::ansi's +# colour_disabled contract deliberately retains non-colour SGR effects, and +# module-side emitters like punkcheck summaries follow that contract - the +# transform is the single choke point that guarantees zero ESC bytes). +# Tcl 8.6 has no boot-phase tty probe (no -winsize/-inputmode anywhere): the +# fail direction is plain/OFF (safe for piped/captured agent runs - unlike +# stdin's assume-interactive; use the force env vars for 8.6 interactive colour). +# stderr follows the same single stdout-keyed decision. +# The colour switch is ::punk::console::colour_disabled; when punk::ansi is +# already loaded (punk-exe-hosted runs) its sgr cache is cleared per that +# module's contract ("whatever function disables or re-enables colour should +# have made a call to punk::ansi::sgr_cache -action clear"). +# ::punkboot::colour_mode records the decision: one of +# tty | forced | nocolor | piped-plain | tcl86-plain +# --------------------------------------------------------------------------- +namespace eval ::punkboot { + variable colour_mode "" +} +namespace eval ::punkboot::ansistrip { + #Write-side channel transform stripping all ANSI escape sequences (CSI incl. + #SGR, OSC with BEL/ST terminators, and two-char ESC sequences). Handles + #sequences split across write chunks via a per-channel carry buffer; an + #unterminated sequence held at flush/finalize time is dropped (it is an + #incomplete ANSI sequence - dropping it IS stripping it). + variable carry [dict create] + variable re_complete {\x1b(?:\[[0-9;:<=>?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\^_])} + proc strip {chanid data} { + variable carry + variable re_complete + if {[dict exists $carry $chanid]} { + set data "[dict get $carry $chanid]$data" + dict unset carry $chanid + } + #hold back a trailing incomplete escape sequence (conservative: from the + #last ESC onward, if it is not a complete sequence it may be a prefix of + #one still being written) + set idx [string last \x1b $data] + if {$idx >= 0} { + set tail [string range $data $idx end] + if {![regexp -- "^${re_complete}" $tail]} { + dict set carry $chanid $tail + set data [string range $data 0 $idx-1] + } + } + regsub -all -- $re_complete $data {} data + return $data + } + proc transchan {op chanid args} { + variable carry + switch -- $op { + initialize { + return [list initialize finalize write flush clear] + } + write { + return [strip $chanid [lindex $args 0]] + } + flush { + return "" + } + clear - + finalize { + dict unset carry $chanid + return + } + } + } +} +apply {{} { + set force 0 + foreach fvar {PUNK_FORCE_COLOR FORCE_COLOR} { + if {[info exists ::env($fvar)] && $::env($fvar) ni {0 false FALSE no NO off OFF}} { + set force 1 + break + } + } + set stdout_is_tty [expr {![catch {chan configure stdout -winsize}]}] + if {[info exists ::env(NO_COLOR)]} { + namespace eval ::punk::console {variable colour_disabled 1} + set ::punkboot::colour_mode nocolor + } elseif {$force} { + namespace eval ::punk::console {variable colour_disabled 0} + set ::punkboot::colour_mode forced + } elseif {$stdout_is_tty} { + namespace eval ::punk::console {variable colour_disabled 0} + set ::punkboot::colour_mode tty + } else { + namespace eval ::punk::console {variable colour_disabled 1} + if {[package vsatisfies [package provide Tcl] 8.7-]} { + set ::punkboot::colour_mode piped-plain + } else { + set ::punkboot::colour_mode tcl86-plain + } + } + #zero-ESC guarantee for piped/unknown output unless the caller forced ANSI on. + #(NO_COLOR keeps punk::ansi's documented colour-only-stripping semantics on a + #terminal; a NO_COLOR run with piped stdout still gets the full strip.) + if {!$force && !$stdout_is_tty} { + chan push stdout ::punkboot::ansistrip::transchan + chan push stderr ::punkboot::ansistrip::transchan + set ::punkboot::ansistrip_pushed 1 + } else { + set ::punkboot::ansistrip_pushed 0 + } + #punk-exe-hosted runs arrive with punk::ansi pre-loaded and a warm sgr cache + #generated under the kit's own colour state - honour the module's contract. + if {[package provide punk::ansi] ne ""} { + catch {punk::ansi::sgr_cache -action clear} + } + if {[info exists ::env(PUNKBOOT_COLOUR_DEBUG)]} { + puts stderr "punkboot colour policy: mode=$::punkboot::colour_mode colour_disabled=$::punk::console::colour_disabled ansistrip=$::punkboot::ansistrip_pushed" + } +}} +# Colour-policy gate for raw-SGR emission sites that can run before +# define_global_ansi / punk::ansi are available (early package-load +# diagnostics and kit-build warnings). Returns the SGR sequence, or an empty +# string when colour is disabled. Empty/omitted params give the reset. +proc ::punkboot::sgr {{sgrparams ""}} { + if {[info exists ::punk::console::colour_disabled] && $::punk::console::colour_disabled} { + return "" + } + return "\x1b\[${sgrparams}m" } set hashline "# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ###" puts $hashline @@ -861,11 +993,11 @@ proc ::punkboot::check_package_availability {args} { #package missing - or exists - but failing to initialise if {!$::opt_quiet} { set parent_path [lrange $::test::pkg_stack 0 end-1] - puts stderr "\x1b\[32m $pkgname versions: $versions error: $result\x1b\[m" + puts stderr "[punkboot::sgr 32] $pkgname versions: $versions error: $result[punkboot::sgr]" set parent_path [join $parent_path " -> "] puts stderr "pkg requirements: $parent_path" puts stderr "error during : '$args'" - puts stderr " \x1b\[93m$result\x1b\[m" + puts stderr " [punkboot::sgr 93]$result[punkboot::sgr]" } #the failed package may still exist - so we could check 'package files' and 'package ifneeded' here too - REVIEW @@ -1353,12 +1485,24 @@ proc ::punkboot::define_global_ansi {} { namespace eval ::punkboot { namespace import ::punk::ansi::a+ ::punk::ansi::a } - set A(RST) \x1b\[m - set A(HIGHLIGHT) [a+ brightyellow] - set A(BWHITE) [a+ brightwhite] - set A(OK) [a+ web-lawngreen] ;#brightgreen - set A(BAD) [a+ web-orange] - set A(ERR) [a+ web-indianred] ;#easier on the eyes than standard red on some screens + if {[info exists ::punk::console::colour_disabled] && $::punk::console::colour_disabled} { + #G-113: the A() palette is colour-only highlighting - when colour is + #disabled every entry (including RST) is empty rather than relying on + #a+/a colour stripping (a bare reset would survive as a non-colour code). + set A(RST) "" + set A(HIGHLIGHT) "" + set A(BWHITE) "" + set A(OK) "" + set A(BAD) "" + set A(ERR) "" + } else { + set A(RST) [a] + set A(HIGHLIGHT) [a+ brightyellow] + set A(BWHITE) [a+ brightwhite] + set A(OK) [a+ web-lawngreen] ;#brightgreen + set A(BAD) [a+ web-orange] + set A(ERR) [a+ web-indianred] ;#easier on the eyes than standard red on some screens + } } } proc ::punkboot::punkboot_gethelp {args} { @@ -2277,6 +2421,9 @@ if {$::punkboot::command in {project packages modules libs vfs vfslibs bin boots if {$::punkboot::command eq "check"} { set sep [string repeat - 75] puts stdout $sep + puts stdout "colour policy (G-113): mode=$::punkboot::colour_mode colour_disabled=$::punk::console::colour_disabled ansistrip=$::punkboot::ansistrip_pushed" + puts stdout " (precedence: NO_COLOR > PUNK_FORCE_COLOR/FORCE_COLOR > stdout tty probe; piped/8.6-unknown output is fully ESC-stripped unless forced)" + puts stdout $sep puts stdout "module/library checks - paths from bootsupport only" puts stdout $sep puts stdout "- tcl::tm::list" @@ -2624,6 +2771,13 @@ if {$::punkboot::command eq "workflow"} { } if {$::punkboot::command eq "shell"} { + #G-113: the ansistrip transform serves make.tcl's own output only - the repl's + #colour behaviour is the shell's own concern (colour on/off + NO_COLOR there). + if {[info exists ::punkboot::ansistrip_pushed] && $::punkboot::ansistrip_pushed} { + catch {chan pop stdout} + catch {chan pop stderr} + set ::punkboot::ansistrip_pushed 0 + } package require struct::list package require punk package require punk::repl @@ -4008,7 +4162,7 @@ proc merge_over {sourcedir targetdir {depth 0}} { set target [file join $targetdir $relpath] if {[file type $actualsource] eq "file"} { #fauxlink linktarget (source data) is a file - puts -nonewline stdout "\x1b\[32m\x1b\[m" + puts -nonewline stdout "[punkboot::sgr 32][punkboot::sgr]" puts "file copy -force $actualsource $target" file copy -force $actualsource $target } else { @@ -4383,7 +4537,7 @@ foreach vfstail $vfs_tails { #the input building_runtime wasn't mountable as a zip - so presumably a plain executable #runtime executable possibly with kit/cookfs etc attached? #If not - init.tcl probably won't be found? should we even proceed ?? - puts stderr "\x1b\[31mWARNING the runtime was not mountable as a zip - which means tcl_library was not extracted - the executable may not work with zipfs attached!\x1b\[m" + puts stderr "[punkboot::sgr 31]WARNING the runtime was not mountable as a zip - which means tcl_library was not extracted - the executable may not work with zipfs attached![punkboot::sgr]" file copy -force $building_runtime $raw_runtime } } else { @@ -4494,7 +4648,7 @@ foreach vfstail $vfs_tails { if {!$extraction_done} { #TODO: if not extracted - use a default tcl_library for patchlevel and platform? puts stderr "--------------------------------------------" - puts stderr "\x1b\31mWARNING: No extraction done from runtime $runtime_fullname\x1b\[m" + puts stderr "[punkboot::sgr 31]WARNING: No extraction done from runtime $runtime_fullname[punkboot::sgr]" puts stderr "If no init.tcl provided in the vfs at the proper location (containing init.tcl) - the resulting kit will probably not initialise properly!" puts stderr "--------------------------------------------" file mkdir $targetvfs diff --git a/src/project_layouts/vendor/punk/basic/src/make.tcl b/src/project_layouts/vendor/punk/basic/src/make.tcl index 36c83222..c9bd881b 100644 --- a/src/project_layouts/vendor/punk/basic/src/make.tcl +++ b/src/project_layouts/vendor/punk/basic/src/make.tcl @@ -2,8 +2,140 @@ # # punkboot - make any tclkits and modules in /src folders and place them and associated data files/scripts in the parent folder of src. #e.g in 'bin' and 'modules' folders at same level as 'src' folder. -if {[info exists ::env(NO_COLOR)]} { - namespace eval ::punk::console {variable colour_disabled 1} +# --------------------------------------------------------------------------- +# Terminal-aware colour/ANSI policy (G-113) - evaluated before any output. +# Precedence: +# 1. NO_COLOR set (any value) -> colour OFF (always wins; punk::ansi +# NO_COLOR semantics: colour codes stripped, non-colour effects like bold +# may remain when output goes to a terminal) +# 2. PUNK_FORCE_COLOR / FORCE_COLOR set -> ANSI ON even when piped +# (a value of 0/false/no/off is treated as not-set, per ecosystem convention) +# 3. stdout tty probe -> colour ON only when stdout is a +# terminal channel (-winsize option present - Tcl 8.7+/9 terminal channels +# only, mirroring the stdin_is_interactive -inputmode precedent) +# Piped/redirected stdout (probe fails, no force) additionally pushes a strip +# transform on stdout+stderr so the output is fully ESC-free - machine +# consumers get plain text no matter which emitter produced it (punk::ansi's +# colour_disabled contract deliberately retains non-colour SGR effects, and +# module-side emitters like punkcheck summaries follow that contract - the +# transform is the single choke point that guarantees zero ESC bytes). +# Tcl 8.6 has no boot-phase tty probe (no -winsize/-inputmode anywhere): the +# fail direction is plain/OFF (safe for piped/captured agent runs - unlike +# stdin's assume-interactive; use the force env vars for 8.6 interactive colour). +# stderr follows the same single stdout-keyed decision. +# The colour switch is ::punk::console::colour_disabled; when punk::ansi is +# already loaded (punk-exe-hosted runs) its sgr cache is cleared per that +# module's contract ("whatever function disables or re-enables colour should +# have made a call to punk::ansi::sgr_cache -action clear"). +# ::punkboot::colour_mode records the decision: one of +# tty | forced | nocolor | piped-plain | tcl86-plain +# --------------------------------------------------------------------------- +namespace eval ::punkboot { + variable colour_mode "" +} +namespace eval ::punkboot::ansistrip { + #Write-side channel transform stripping all ANSI escape sequences (CSI incl. + #SGR, OSC with BEL/ST terminators, and two-char ESC sequences). Handles + #sequences split across write chunks via a per-channel carry buffer; an + #unterminated sequence held at flush/finalize time is dropped (it is an + #incomplete ANSI sequence - dropping it IS stripping it). + variable carry [dict create] + variable re_complete {\x1b(?:\[[0-9;:<=>?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\^_])} + proc strip {chanid data} { + variable carry + variable re_complete + if {[dict exists $carry $chanid]} { + set data "[dict get $carry $chanid]$data" + dict unset carry $chanid + } + #hold back a trailing incomplete escape sequence (conservative: from the + #last ESC onward, if it is not a complete sequence it may be a prefix of + #one still being written) + set idx [string last \x1b $data] + if {$idx >= 0} { + set tail [string range $data $idx end] + if {![regexp -- "^${re_complete}" $tail]} { + dict set carry $chanid $tail + set data [string range $data 0 $idx-1] + } + } + regsub -all -- $re_complete $data {} data + return $data + } + proc transchan {op chanid args} { + variable carry + switch -- $op { + initialize { + return [list initialize finalize write flush clear] + } + write { + return [strip $chanid [lindex $args 0]] + } + flush { + return "" + } + clear - + finalize { + dict unset carry $chanid + return + } + } + } +} +apply {{} { + set force 0 + foreach fvar {PUNK_FORCE_COLOR FORCE_COLOR} { + if {[info exists ::env($fvar)] && $::env($fvar) ni {0 false FALSE no NO off OFF}} { + set force 1 + break + } + } + set stdout_is_tty [expr {![catch {chan configure stdout -winsize}]}] + if {[info exists ::env(NO_COLOR)]} { + namespace eval ::punk::console {variable colour_disabled 1} + set ::punkboot::colour_mode nocolor + } elseif {$force} { + namespace eval ::punk::console {variable colour_disabled 0} + set ::punkboot::colour_mode forced + } elseif {$stdout_is_tty} { + namespace eval ::punk::console {variable colour_disabled 0} + set ::punkboot::colour_mode tty + } else { + namespace eval ::punk::console {variable colour_disabled 1} + if {[package vsatisfies [package provide Tcl] 8.7-]} { + set ::punkboot::colour_mode piped-plain + } else { + set ::punkboot::colour_mode tcl86-plain + } + } + #zero-ESC guarantee for piped/unknown output unless the caller forced ANSI on. + #(NO_COLOR keeps punk::ansi's documented colour-only-stripping semantics on a + #terminal; a NO_COLOR run with piped stdout still gets the full strip.) + if {!$force && !$stdout_is_tty} { + chan push stdout ::punkboot::ansistrip::transchan + chan push stderr ::punkboot::ansistrip::transchan + set ::punkboot::ansistrip_pushed 1 + } else { + set ::punkboot::ansistrip_pushed 0 + } + #punk-exe-hosted runs arrive with punk::ansi pre-loaded and a warm sgr cache + #generated under the kit's own colour state - honour the module's contract. + if {[package provide punk::ansi] ne ""} { + catch {punk::ansi::sgr_cache -action clear} + } + if {[info exists ::env(PUNKBOOT_COLOUR_DEBUG)]} { + puts stderr "punkboot colour policy: mode=$::punkboot::colour_mode colour_disabled=$::punk::console::colour_disabled ansistrip=$::punkboot::ansistrip_pushed" + } +}} +# Colour-policy gate for raw-SGR emission sites that can run before +# define_global_ansi / punk::ansi are available (early package-load +# diagnostics and kit-build warnings). Returns the SGR sequence, or an empty +# string when colour is disabled. Empty/omitted params give the reset. +proc ::punkboot::sgr {{sgrparams ""}} { + if {[info exists ::punk::console::colour_disabled] && $::punk::console::colour_disabled} { + return "" + } + return "\x1b\[${sgrparams}m" } set hashline "# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ###" puts $hashline @@ -861,11 +993,11 @@ proc ::punkboot::check_package_availability {args} { #package missing - or exists - but failing to initialise if {!$::opt_quiet} { set parent_path [lrange $::test::pkg_stack 0 end-1] - puts stderr "\x1b\[32m $pkgname versions: $versions error: $result\x1b\[m" + puts stderr "[punkboot::sgr 32] $pkgname versions: $versions error: $result[punkboot::sgr]" set parent_path [join $parent_path " -> "] puts stderr "pkg requirements: $parent_path" puts stderr "error during : '$args'" - puts stderr " \x1b\[93m$result\x1b\[m" + puts stderr " [punkboot::sgr 93]$result[punkboot::sgr]" } #the failed package may still exist - so we could check 'package files' and 'package ifneeded' here too - REVIEW @@ -1353,12 +1485,24 @@ proc ::punkboot::define_global_ansi {} { namespace eval ::punkboot { namespace import ::punk::ansi::a+ ::punk::ansi::a } - set A(RST) \x1b\[m - set A(HIGHLIGHT) [a+ brightyellow] - set A(BWHITE) [a+ brightwhite] - set A(OK) [a+ web-lawngreen] ;#brightgreen - set A(BAD) [a+ web-orange] - set A(ERR) [a+ web-indianred] ;#easier on the eyes than standard red on some screens + if {[info exists ::punk::console::colour_disabled] && $::punk::console::colour_disabled} { + #G-113: the A() palette is colour-only highlighting - when colour is + #disabled every entry (including RST) is empty rather than relying on + #a+/a colour stripping (a bare reset would survive as a non-colour code). + set A(RST) "" + set A(HIGHLIGHT) "" + set A(BWHITE) "" + set A(OK) "" + set A(BAD) "" + set A(ERR) "" + } else { + set A(RST) [a] + set A(HIGHLIGHT) [a+ brightyellow] + set A(BWHITE) [a+ brightwhite] + set A(OK) [a+ web-lawngreen] ;#brightgreen + set A(BAD) [a+ web-orange] + set A(ERR) [a+ web-indianred] ;#easier on the eyes than standard red on some screens + } } } proc ::punkboot::punkboot_gethelp {args} { @@ -2277,6 +2421,9 @@ if {$::punkboot::command in {project packages modules libs vfs vfslibs bin boots if {$::punkboot::command eq "check"} { set sep [string repeat - 75] puts stdout $sep + puts stdout "colour policy (G-113): mode=$::punkboot::colour_mode colour_disabled=$::punk::console::colour_disabled ansistrip=$::punkboot::ansistrip_pushed" + puts stdout " (precedence: NO_COLOR > PUNK_FORCE_COLOR/FORCE_COLOR > stdout tty probe; piped/8.6-unknown output is fully ESC-stripped unless forced)" + puts stdout $sep puts stdout "module/library checks - paths from bootsupport only" puts stdout $sep puts stdout "- tcl::tm::list" @@ -2624,6 +2771,13 @@ if {$::punkboot::command eq "workflow"} { } if {$::punkboot::command eq "shell"} { + #G-113: the ansistrip transform serves make.tcl's own output only - the repl's + #colour behaviour is the shell's own concern (colour on/off + NO_COLOR there). + if {[info exists ::punkboot::ansistrip_pushed] && $::punkboot::ansistrip_pushed} { + catch {chan pop stdout} + catch {chan pop stderr} + set ::punkboot::ansistrip_pushed 0 + } package require struct::list package require punk package require punk::repl @@ -4008,7 +4162,7 @@ proc merge_over {sourcedir targetdir {depth 0}} { set target [file join $targetdir $relpath] if {[file type $actualsource] eq "file"} { #fauxlink linktarget (source data) is a file - puts -nonewline stdout "\x1b\[32m\x1b\[m" + puts -nonewline stdout "[punkboot::sgr 32][punkboot::sgr]" puts "file copy -force $actualsource $target" file copy -force $actualsource $target } else { @@ -4383,7 +4537,7 @@ foreach vfstail $vfs_tails { #the input building_runtime wasn't mountable as a zip - so presumably a plain executable #runtime executable possibly with kit/cookfs etc attached? #If not - init.tcl probably won't be found? should we even proceed ?? - puts stderr "\x1b\[31mWARNING the runtime was not mountable as a zip - which means tcl_library was not extracted - the executable may not work with zipfs attached!\x1b\[m" + puts stderr "[punkboot::sgr 31]WARNING the runtime was not mountable as a zip - which means tcl_library was not extracted - the executable may not work with zipfs attached![punkboot::sgr]" file copy -force $building_runtime $raw_runtime } } else { @@ -4494,7 +4648,7 @@ foreach vfstail $vfs_tails { if {!$extraction_done} { #TODO: if not extracted - use a default tcl_library for patchlevel and platform? puts stderr "--------------------------------------------" - puts stderr "\x1b\31mWARNING: No extraction done from runtime $runtime_fullname\x1b\[m" + puts stderr "[punkboot::sgr 31]WARNING: No extraction done from runtime $runtime_fullname[punkboot::sgr]" puts stderr "If no init.tcl provided in the vfs at the proper location (containing init.tcl) - the resulting kit will probably not initialise properly!" puts stderr "--------------------------------------------" file mkdir $targetvfs diff --git a/src/project_layouts/vendor/punk/project-0.1/src/make.tcl b/src/project_layouts/vendor/punk/project-0.1/src/make.tcl index 36c83222..c9bd881b 100644 --- a/src/project_layouts/vendor/punk/project-0.1/src/make.tcl +++ b/src/project_layouts/vendor/punk/project-0.1/src/make.tcl @@ -2,8 +2,140 @@ # # punkboot - make any tclkits and modules in /src folders and place them and associated data files/scripts in the parent folder of src. #e.g in 'bin' and 'modules' folders at same level as 'src' folder. -if {[info exists ::env(NO_COLOR)]} { - namespace eval ::punk::console {variable colour_disabled 1} +# --------------------------------------------------------------------------- +# Terminal-aware colour/ANSI policy (G-113) - evaluated before any output. +# Precedence: +# 1. NO_COLOR set (any value) -> colour OFF (always wins; punk::ansi +# NO_COLOR semantics: colour codes stripped, non-colour effects like bold +# may remain when output goes to a terminal) +# 2. PUNK_FORCE_COLOR / FORCE_COLOR set -> ANSI ON even when piped +# (a value of 0/false/no/off is treated as not-set, per ecosystem convention) +# 3. stdout tty probe -> colour ON only when stdout is a +# terminal channel (-winsize option present - Tcl 8.7+/9 terminal channels +# only, mirroring the stdin_is_interactive -inputmode precedent) +# Piped/redirected stdout (probe fails, no force) additionally pushes a strip +# transform on stdout+stderr so the output is fully ESC-free - machine +# consumers get plain text no matter which emitter produced it (punk::ansi's +# colour_disabled contract deliberately retains non-colour SGR effects, and +# module-side emitters like punkcheck summaries follow that contract - the +# transform is the single choke point that guarantees zero ESC bytes). +# Tcl 8.6 has no boot-phase tty probe (no -winsize/-inputmode anywhere): the +# fail direction is plain/OFF (safe for piped/captured agent runs - unlike +# stdin's assume-interactive; use the force env vars for 8.6 interactive colour). +# stderr follows the same single stdout-keyed decision. +# The colour switch is ::punk::console::colour_disabled; when punk::ansi is +# already loaded (punk-exe-hosted runs) its sgr cache is cleared per that +# module's contract ("whatever function disables or re-enables colour should +# have made a call to punk::ansi::sgr_cache -action clear"). +# ::punkboot::colour_mode records the decision: one of +# tty | forced | nocolor | piped-plain | tcl86-plain +# --------------------------------------------------------------------------- +namespace eval ::punkboot { + variable colour_mode "" +} +namespace eval ::punkboot::ansistrip { + #Write-side channel transform stripping all ANSI escape sequences (CSI incl. + #SGR, OSC with BEL/ST terminators, and two-char ESC sequences). Handles + #sequences split across write chunks via a per-channel carry buffer; an + #unterminated sequence held at flush/finalize time is dropped (it is an + #incomplete ANSI sequence - dropping it IS stripping it). + variable carry [dict create] + variable re_complete {\x1b(?:\[[0-9;:<=>?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\^_])} + proc strip {chanid data} { + variable carry + variable re_complete + if {[dict exists $carry $chanid]} { + set data "[dict get $carry $chanid]$data" + dict unset carry $chanid + } + #hold back a trailing incomplete escape sequence (conservative: from the + #last ESC onward, if it is not a complete sequence it may be a prefix of + #one still being written) + set idx [string last \x1b $data] + if {$idx >= 0} { + set tail [string range $data $idx end] + if {![regexp -- "^${re_complete}" $tail]} { + dict set carry $chanid $tail + set data [string range $data 0 $idx-1] + } + } + regsub -all -- $re_complete $data {} data + return $data + } + proc transchan {op chanid args} { + variable carry + switch -- $op { + initialize { + return [list initialize finalize write flush clear] + } + write { + return [strip $chanid [lindex $args 0]] + } + flush { + return "" + } + clear - + finalize { + dict unset carry $chanid + return + } + } + } +} +apply {{} { + set force 0 + foreach fvar {PUNK_FORCE_COLOR FORCE_COLOR} { + if {[info exists ::env($fvar)] && $::env($fvar) ni {0 false FALSE no NO off OFF}} { + set force 1 + break + } + } + set stdout_is_tty [expr {![catch {chan configure stdout -winsize}]}] + if {[info exists ::env(NO_COLOR)]} { + namespace eval ::punk::console {variable colour_disabled 1} + set ::punkboot::colour_mode nocolor + } elseif {$force} { + namespace eval ::punk::console {variable colour_disabled 0} + set ::punkboot::colour_mode forced + } elseif {$stdout_is_tty} { + namespace eval ::punk::console {variable colour_disabled 0} + set ::punkboot::colour_mode tty + } else { + namespace eval ::punk::console {variable colour_disabled 1} + if {[package vsatisfies [package provide Tcl] 8.7-]} { + set ::punkboot::colour_mode piped-plain + } else { + set ::punkboot::colour_mode tcl86-plain + } + } + #zero-ESC guarantee for piped/unknown output unless the caller forced ANSI on. + #(NO_COLOR keeps punk::ansi's documented colour-only-stripping semantics on a + #terminal; a NO_COLOR run with piped stdout still gets the full strip.) + if {!$force && !$stdout_is_tty} { + chan push stdout ::punkboot::ansistrip::transchan + chan push stderr ::punkboot::ansistrip::transchan + set ::punkboot::ansistrip_pushed 1 + } else { + set ::punkboot::ansistrip_pushed 0 + } + #punk-exe-hosted runs arrive with punk::ansi pre-loaded and a warm sgr cache + #generated under the kit's own colour state - honour the module's contract. + if {[package provide punk::ansi] ne ""} { + catch {punk::ansi::sgr_cache -action clear} + } + if {[info exists ::env(PUNKBOOT_COLOUR_DEBUG)]} { + puts stderr "punkboot colour policy: mode=$::punkboot::colour_mode colour_disabled=$::punk::console::colour_disabled ansistrip=$::punkboot::ansistrip_pushed" + } +}} +# Colour-policy gate for raw-SGR emission sites that can run before +# define_global_ansi / punk::ansi are available (early package-load +# diagnostics and kit-build warnings). Returns the SGR sequence, or an empty +# string when colour is disabled. Empty/omitted params give the reset. +proc ::punkboot::sgr {{sgrparams ""}} { + if {[info exists ::punk::console::colour_disabled] && $::punk::console::colour_disabled} { + return "" + } + return "\x1b\[${sgrparams}m" } set hashline "# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ###" puts $hashline @@ -861,11 +993,11 @@ proc ::punkboot::check_package_availability {args} { #package missing - or exists - but failing to initialise if {!$::opt_quiet} { set parent_path [lrange $::test::pkg_stack 0 end-1] - puts stderr "\x1b\[32m $pkgname versions: $versions error: $result\x1b\[m" + puts stderr "[punkboot::sgr 32] $pkgname versions: $versions error: $result[punkboot::sgr]" set parent_path [join $parent_path " -> "] puts stderr "pkg requirements: $parent_path" puts stderr "error during : '$args'" - puts stderr " \x1b\[93m$result\x1b\[m" + puts stderr " [punkboot::sgr 93]$result[punkboot::sgr]" } #the failed package may still exist - so we could check 'package files' and 'package ifneeded' here too - REVIEW @@ -1353,12 +1485,24 @@ proc ::punkboot::define_global_ansi {} { namespace eval ::punkboot { namespace import ::punk::ansi::a+ ::punk::ansi::a } - set A(RST) \x1b\[m - set A(HIGHLIGHT) [a+ brightyellow] - set A(BWHITE) [a+ brightwhite] - set A(OK) [a+ web-lawngreen] ;#brightgreen - set A(BAD) [a+ web-orange] - set A(ERR) [a+ web-indianred] ;#easier on the eyes than standard red on some screens + if {[info exists ::punk::console::colour_disabled] && $::punk::console::colour_disabled} { + #G-113: the A() palette is colour-only highlighting - when colour is + #disabled every entry (including RST) is empty rather than relying on + #a+/a colour stripping (a bare reset would survive as a non-colour code). + set A(RST) "" + set A(HIGHLIGHT) "" + set A(BWHITE) "" + set A(OK) "" + set A(BAD) "" + set A(ERR) "" + } else { + set A(RST) [a] + set A(HIGHLIGHT) [a+ brightyellow] + set A(BWHITE) [a+ brightwhite] + set A(OK) [a+ web-lawngreen] ;#brightgreen + set A(BAD) [a+ web-orange] + set A(ERR) [a+ web-indianred] ;#easier on the eyes than standard red on some screens + } } } proc ::punkboot::punkboot_gethelp {args} { @@ -2277,6 +2421,9 @@ if {$::punkboot::command in {project packages modules libs vfs vfslibs bin boots if {$::punkboot::command eq "check"} { set sep [string repeat - 75] puts stdout $sep + puts stdout "colour policy (G-113): mode=$::punkboot::colour_mode colour_disabled=$::punk::console::colour_disabled ansistrip=$::punkboot::ansistrip_pushed" + puts stdout " (precedence: NO_COLOR > PUNK_FORCE_COLOR/FORCE_COLOR > stdout tty probe; piped/8.6-unknown output is fully ESC-stripped unless forced)" + puts stdout $sep puts stdout "module/library checks - paths from bootsupport only" puts stdout $sep puts stdout "- tcl::tm::list" @@ -2624,6 +2771,13 @@ if {$::punkboot::command eq "workflow"} { } if {$::punkboot::command eq "shell"} { + #G-113: the ansistrip transform serves make.tcl's own output only - the repl's + #colour behaviour is the shell's own concern (colour on/off + NO_COLOR there). + if {[info exists ::punkboot::ansistrip_pushed] && $::punkboot::ansistrip_pushed} { + catch {chan pop stdout} + catch {chan pop stderr} + set ::punkboot::ansistrip_pushed 0 + } package require struct::list package require punk package require punk::repl @@ -4008,7 +4162,7 @@ proc merge_over {sourcedir targetdir {depth 0}} { set target [file join $targetdir $relpath] if {[file type $actualsource] eq "file"} { #fauxlink linktarget (source data) is a file - puts -nonewline stdout "\x1b\[32m\x1b\[m" + puts -nonewline stdout "[punkboot::sgr 32][punkboot::sgr]" puts "file copy -force $actualsource $target" file copy -force $actualsource $target } else { @@ -4383,7 +4537,7 @@ foreach vfstail $vfs_tails { #the input building_runtime wasn't mountable as a zip - so presumably a plain executable #runtime executable possibly with kit/cookfs etc attached? #If not - init.tcl probably won't be found? should we even proceed ?? - puts stderr "\x1b\[31mWARNING the runtime was not mountable as a zip - which means tcl_library was not extracted - the executable may not work with zipfs attached!\x1b\[m" + puts stderr "[punkboot::sgr 31]WARNING the runtime was not mountable as a zip - which means tcl_library was not extracted - the executable may not work with zipfs attached![punkboot::sgr]" file copy -force $building_runtime $raw_runtime } } else { @@ -4494,7 +4648,7 @@ foreach vfstail $vfs_tails { if {!$extraction_done} { #TODO: if not extracted - use a default tcl_library for patchlevel and platform? puts stderr "--------------------------------------------" - puts stderr "\x1b\31mWARNING: No extraction done from runtime $runtime_fullname\x1b\[m" + puts stderr "[punkboot::sgr 31]WARNING: No extraction done from runtime $runtime_fullname[punkboot::sgr]" puts stderr "If no init.tcl provided in the vfs at the proper location (containing init.tcl) - the resulting kit will probably not initialise properly!" puts stderr "--------------------------------------------" file mkdir $targetvfs diff --git a/src/tests/shell/testsuites/punkexe/maketclcolour.test b/src/tests/shell/testsuites/punkexe/maketclcolour.test new file mode 100644 index 00000000..7623b099 --- /dev/null +++ b/src/tests/shell/testsuites/punkexe/maketclcolour.test @@ -0,0 +1,150 @@ +package require tcltest + +#Piped-output colour characterization for make.tcl's terminal-aware colour policy +#(goal G-113). Runs the WORKING TREE's src/make.tcl under the built punk executable's +#'script' subcommand with output captured through a pipe, and pins: +# - piped output with NO_COLOR unset is fully ESC-free (the ansistrip transform) +# - the check subcommand reports the policy decision (mode=piped-plain, ansistrip=1) +# - FORCE_COLOR re-enables ANSI/colour on piped output (mode=forced) +# - NO_COLOR=1 piped output remains fully ESC-free +#The interactive (tty) side cannot be pinned from a piped harness - evidence for it +#is recorded in the G-113 goal file (hidden-console + WSL pty runs, mode=tty). +# +#Target executable resolved from env(PUNK_SHELL_TEST_EXE), else /bin/punk902z.exe +#then /bin/punkshell902. Skipped (constraint punkexeavailable) if none found. + +namespace eval ::testspace { + namespace import ::tcltest::* + + variable testdir [file dirname [file normalize [info script]]] + #/src/tests/shell/testsuites/punkexe -> 5 levels up to + variable projectroot [file normalize [file join $testdir .. .. .. .. ..]] + variable maketcl [file join $projectroot src make.tcl] + + variable punkexe "" + if {[info exists ::env(PUNK_SHELL_TEST_EXE)] && $::env(PUNK_SHELL_TEST_EXE) ne ""} { + set punkexe [file normalize $::env(PUNK_SHELL_TEST_EXE)] + } else { + foreach candidate [list [file join $projectroot bin punk902z.exe] [file join $projectroot bin punkshell902]] { + if {[file exists $candidate]} { + set punkexe $candidate + break + } + } + } + testConstraint punkexeavailable [expr {$punkexe ne "" && [file exists $punkexe]}] + + variable maketcl_run_timeout_ms 60000 + + variable runstate + array set runstate {} + + proc maketcl_run_read {chan} { + variable runstate + append runstate(output) [read $chan] + if {[chan eof $chan]} { + chan event $chan readable {} + set runstate(done) eof + } + } + + #Run script src/make.tcl with a controlled colour env. + #envoverrides is a dict: name value pairs applied to ::env for the child (a value + #of "" means unset). Saved values are restored after the run. Returns dict: + #timedout 0|1, exitcode , output . + proc maketcl_run {cmdargs envoverrides} { + variable runstate + variable maketcl_run_timeout_ms + variable punkexe + variable maketcl + array unset runstate + set runstate(output) "" + set runstate(done) "" + + set saved [dict create] + dict for {evar eval_} $envoverrides { + if {[info exists ::env($evar)]} { + dict set saved $evar [list 1 $::env($evar)] + } else { + dict set saved $evar [list 0 {}] + } + if {$eval_ eq ""} { + unset -nocomplain ::env($evar) + } else { + set ::env($evar) $eval_ + } + } + try { + set chan [open |[list $punkexe script $maketcl {*}$cmdargs 2>@1] r+] + chan configure $chan -blocking 0 -translation binary + catch {chan close $chan write} ;#no stdin for the child - immediate EOF + set timerid [after $maketcl_run_timeout_ms [list set [namespace current]::runstate(done) timeout]] + chan event $chan readable [list [namespace current]::maketcl_run_read $chan] + while {$runstate(done) eq ""} { + vwait [namespace current]::runstate(done) + } + after cancel $timerid + set timedout [expr {$runstate(done) eq "timeout"}] + set exitcode "" + if {$timedout} { + catch {exec {*}[auto_execok taskkill] /F /T /PID [lindex [pid $chan] 0]} + catch {chan close $chan} + } else { + chan configure $chan -blocking 1 + if {[catch {chan close $chan} errdata errdict]} { + set exitcode [lindex [dict get $errdict -errorcode] end] + } else { + set exitcode 0 + } + } + return [dict create timedout $timedout exitcode $exitcode output $runstate(output)] + } finally { + dict for {evar restore} $saved { + lassign $restore existed oldval + if {$existed} { + set ::env($evar) $oldval + } else { + unset -nocomplain ::env($evar) + } + } + } + } + + proc esc_count {text} { + return [regexp -all {\x1b} $text] + } + + #added 2026-07-25 (agent, G-113) + test maketcl_colour_piped_plain_zero_esc {piped make.tcl check with NO_COLOR unset: zero ESC bytes, policy line reports piped-plain with ansistrip} -constraints {punkexeavailable} -body { + set r [maketcl_run {check} {NO_COLOR "" FORCE_COLOR "" PUNK_FORCE_COLOR ""}] + set result [list] + lappend result timedout [dict get $r timedout] exitcode [dict get $r exitcode] + lappend result esc [esc_count [dict get $r output]] + lappend result policyline [regexp {colour policy \(G-113\): mode=piped-plain colour_disabled=1 ansistrip=1} [dict get $r output]] + set result + } -result {timedout 0 exitcode 0 esc 0 policyline 1} + + #added 2026-07-25 (agent, G-113) + test maketcl_colour_forced_piped_has_colour {FORCE_COLOR=1 re-enables ANSI colour on piped make.tcl help output} -constraints {punkexeavailable} -body { + set r [maketcl_run {help} {NO_COLOR "" FORCE_COLOR 1 PUNK_FORCE_COLOR ""}] + set result [list] + lappend result timedout [dict get $r timedout] exitcode [dict get $r exitcode] + lappend result has_esc [expr {[esc_count [dict get $r output]] > 0}] + #at least one SGR sequence carrying a colour parameter (30-49 / 90-107 fg-bg range or 38;/48; extended) + lappend result has_colour [regexp {\x1b\[[0-9;:]*(?:3[0-9]|4[0-9]|9[0-7]|10[0-7])[;m]} [dict get $r output]] + set result + } -result {timedout 0 exitcode 0 has_esc 1 has_colour 1} + + #added 2026-07-25 (agent, G-113) + test maketcl_colour_nocolor_piped_zero_esc {NO_COLOR=1 piped make.tcl check output is fully ESC-free (strip transform applies)} -constraints {punkexeavailable} -body { + set r [maketcl_run {check} {NO_COLOR 1 FORCE_COLOR "" PUNK_FORCE_COLOR ""}] + set result [list] + lappend result timedout [dict get $r timedout] exitcode [dict get $r exitcode] + lappend result esc [esc_count [dict get $r output]] + lappend result policyline [regexp {colour policy \(G-113\): mode=nocolor colour_disabled=1 ansistrip=1} [dict get $r output]] + set result + } -result {timedout 0 exitcode 0 esc 0 policyline 1} + + cleanupTests +} +namespace delete ::testspace