10 KiB
G-145 Remnant-free piped usage tables: root-cause split ANSI fragments
Status: achieved 2026-08-01 Scope: src/make.tcl (::punkboot::ansistrip transform); src/modules/textblock-999999.0a1.0.tm + src/modules/punk/ansi-999999.0a1.0.tm (ANSI-aware cell/width handling as implicated); src/tests/shell/testsuites/punkexe/maketclcolour.test (remnant pin) Goal: Piped make.tcl usage/help output is free of ANSI remnant text: the orphan CSI fragments observed in piped help tables ('[0;1m', '[0m', lone 'm' beside cell borders) are root-caused (renderer-side sequence splitting vs a strip-transform edge case) and eliminated at the producing layer, extending the G-113 zero-ESC guarantee to zero orphan sequence-fragment text while leaving interactive colour output unchanged. Acceptance: piped 'tclsh src/make.tcl help ' for every declared subject plus bare 'make.tcl -help' yields output with no ESC bytes and no CSI-remnant substrings (regex {[[0-9;:]*m}; the help corpus carries no such literal text), pinned in maketclcolour.test; the root cause and chosen fix layer are recorded in this file; existing colour-policy pins (tty/forced/nocolor modes) pass unchanged.
Context
Observed 2026-08-01 while diffing before/after help captures for the argdoc restyle: piped help output intermittently carries ESC-less ANSI tails inside rendered tables (before the restyle: _toplevel x2, help, modules, vendorupdate; after: _toplevel x4, bakelist, bootsupport, help, info, libs - positions shift with table geometry, so the restyle did not introduce the class). Examples: '[0;1m' at a line end before a cell border, '[0m' at a row start, a lone 'm' beside a border.
Approach
- Evidence so far: the G-113 write-side strip (::punkboot::ansistrip) already carries a trailing incomplete sequence across write chunks (per-channel carry; flush preserves it, finalize drops it), so a clean ESC-boundary split at the channel would be handled. First suspect is therefore upstream: styled content split by ANSI-aware width/cell handling before write, leaving ESC-less fragment text a byte-level strip correctly ignores. (First suspect REFUTED 2026-08-01 - root cause is transform-side, see Notes.)
- Piped runs do legitimately receive ANSI from module emitters (the strip is load-bearing by design - G-113); punk::args resolve caching also carries a colour-state staleness todo (cached specs can embed colour from resolve time), a possible fragment source to check.
- Diagnosis route: capture the pre-transform stream for an affected subject (transform popped, or PUNK_FORCE_COLOR run) and determine whether fragments exist pre-strip; fix in textblock/punk::ansi width handling if so, else harden the transform.
Notes
- ROOT CAUSE (determined 2026-08-01): transform-lifecycle defect in
::punkboot::ansistrip(src/make.tcl), NOT renderer-side splitting. The Tcl core delivers aclearop to the transform stack before EVERY write op (output-buffer flush-down; observed on tclsh 8.7 and 9.0.3, on the real stdout stack and on a bare file channel with no make.tcl code involved).ansistrip::transchan'sclearhandler runsdict unset carry $chanid, discarding a split sequence's held tail at every chunk boundary: write #k ends mid-sequence (e.gESC[held in carry),cleardrops it, write #k+1 begins with0;1mwhich passes through unstripped, and the heldESC[bytes are never emitted. The dropped ESC bytes are why the G-113 zero-ESC pin (maketclcolour.test) stays green while fragments leak. Evidence chain: (a) pre-transform stream captured viaNO_COLOR=1+PUNK_FORCE_COLOR=1(NO_COLOR wins colour, force skips the transform push) is fully well-formed - everyESC[...mcomplete; textblock and punk::ansi cell/width handling are exonerated, and G-056 is not the mechanism. (b) the verbatimstripproc fed the captured stream under every possible 2-chunk split and 512..8192 block sizes yields zero fragments - the carry logic itself is sound for arbitrary chunking. (c) an identity logging transform pushed on stdout before sourcing make.tcl (ansistrip stacks on top) recordedclearbefore everywrite; the output chunk boundary landed exactly at the observed0;1mremnant (prior chunk ended with the Default-cell spaces, the followingESC[held then dropped). (d) minimal isolated repro (verbatim transform on a file channel, repetitive styled payload, no make.tcl): op log showsclearper flush-down, output carries a fragment (;1m). Determinism: fragments are deterministic per table geometry (buffer-boundary positions are fixed; a remnant appears only where a boundary lands inside a sequence), matching the observed intermittency and position shifts across subjects/restyles. - Upstream documentation status (2026-08-01, TEMP_REFERENCE/tcl9 survey): the per-write
clearis INTENTIONAL Tcl core behavior carrying an upstream documentation gap.generic/tclIORTrans.cReflectOutput(~line 1280) invokes the transform'sclearbefore every write when METH_CLEAR is declared, with the comment "Discard partial data in the input buffers, i.e. on the read side. Like we do when explicitly seeking as well." The core test suite pins it:tests/ioTrans.testiortrans-7.1 "chan write, write clears read buffers" expects{clear rt*} {write rt* snarf}from a plain puts+flush (7.2 seek, 7.3 result-ignored, 7.4 lifecycle bug 2921116). Butdoc/transchan.n(lines 47-54) documentsclearas "called when a chan seek is performed on the channel being transformed", and TIP 230's text scopes it to "internal input buffers" that "happens only when the user seeks the channel the transformation is attached to" - both silent on the write-path call. Verdict: upstream DOCUMENTATION bug (behavior is deliberate and test-pinned; man page and TIP text are stale), not an implementation bug; no existing Tcl ticket found covering the discrepancy (one tangential refchan write-buffering ticket, de232b49f2, unrelated to transforms). Two consequences for the fix: (a)clearonly fires at all when the transform DECLARES it (the core gates on METH_CLEAR), so dropping it from the supported-methods list eliminates the per-write calls entirely; (b)clear's documented scope is INPUT/read-side buffers - a write-only transform like ::punkboot::ansistrip has no read-side state, so its contract-conformantclearis a no-op; dropping the write-side carry was wrong under both the documented and the actual contract. - Fix layer: the transform's
clearhandling in src/make.tcl (::punkboot::ansistrip::transchan) -clearmust not discard the per-channel carry (drop the op from the supported-methods list or make it a no-op);finalizedropping the carry remains correct (incomplete sequence at end of stream). No change needed in textblock/punk::ansi. - Encoding note (raised during investigation): the byte-level transform is encoding-safe for its approach - 0x1B never occurs inside UTF-8 multibyte chars (a box char split
e2 94 | 81across writes was observed and is harmless), and utf-16-class channels are guarded at push time (ESC would arrive as1B 00). Compareshellfilter::chan::ansistrip(shellfilter-999999.0a1.0.tm): it decodes viaencoding convertfromand carrieso_encbuffor split multibyte chars ("bytes can break at arbitrary points making encoding conversions invalid"), but explicitly assumes line buffering and does not handle split ANSI codes at all. The punkboot defect is lifecycle (clear), not encoding. - Repro (2026-08-01, current geometry, tclsh 9.0.3, all deterministic): bare
tclsh src/make.tcl helppiped -> 1 fragment (0;1m);help libs/help info->;1mx1 each;help bootsupport->1mx1. Detector regex:(?<![\x1b\[])[0-9;]+m(bracket is eaten with the ESC, so the G-113-era{\[[0-9;:]*m}pattern alone would miss these). In-shell:set out [runout -n tclsh src/make.tcl help]thenregexp -all -inline -- {(?:^|[^\x1b\[])[0-9;]+m} $out(exec/runout pipe stdout, engaging the transform). - G-113 achieved 2026-07-25 (goals/archive/G-113-maketcl-tty-aware-colour.md) - the colour policy and zero-ESC pin surface (maketclcolour.test) this extends.
- Related: G-056 (proposed) - ANSI-aware wrap/width machinery ('styling in effect carries across the wrap'); if the root cause is renderer-side splitting, the fix layer coincides.
- Overlap survey 2026-08-01: goals_xref paths punk/ansi module -> G-078/G-079/G-080 (HTML rendering family, unrelated); textblock -> G-048 (table option parse), G-056 (named above), G-088 (footer rendering) - only G-056 shares the mechanism; punkexe tests -> G-077/G-131/G-141 (unrelated).
Progress
- 2026-08-01 (activated and achieved same day; user instruction): fix landed in src/make.tcl -
::punkboot::ansistrip::transchanno longer declaresclearin its supported-methods list, so the core's per-writeclearops never fire and the per-channel split-sequence carry survives every write-chunk boundary;finalizestill drops the carry (incomplete sequence at end of stream). No change in textblock/punk::ansi (exonerated, note (a)). - Pin: maketclcolour.test gains maketcl_colour_help_tables_zero_remnants - piped
tclsh src/make.tcl helpfor all 20 declared SUMMARIES subjects plus barehelpand-help, asserting exitcode 0, zero ESC bytes, and zero orphan fragments. Two-step detector: strip complete sequences (the transform's own re_complete), then scan the remainder for(?:^|[^\x1b\[])[0-9;]+m- scanning raw output false-positives on multi-parameter SGR (';' inside a complete ESC[0;1m); when esc==0 holds the strip is a no-op. The pin failed pre-fix naming exactly the 5 then-fragmenting invocations (help, -help, help libs, help info, help bootsupport), proving detection; the NO_COLOR+PUNK_FORCE_COLOR pre-transform corpus (all 22 invocations, complete sequences stripped) carries no legit text matching the detector. - Verified 2026-08-01 (tclsh 8.7a6, win32-x86_64): all 22 invocations piped clean (0 ESC bytes, 0 remnants);
PUNK_FORCE_COLOR=1piped help carries 1177 complete CSI sequences and zero orphan fragments (interactive/forced-colour path unchanged); the 3 existing G-113 colour-policy pins (tty-probe piped-plain, forced, nocolor modes) pass unchanged; full shell/testsuites/punkexe family green (15 files, 102 tests, 0 failures) and the shell/*** subtree green (127 tests, 0 failures - the single dtplite.test missing-cleanupTests warning is pre-existing, unrelated).