Browse Source

G-173 closeout: remove proc-body line-continuations; add linecont_lint + mandate -& doc blocks

The G-173 structured-renderer procs (path_conflicts_for_exe, path_entries,
path_render_text, path_render_json, and the punk::path dispatch tail) were
authored with \ line-continuation backslashes in proc bodies, violating
src/modules/AGENTS.md (debug line-number matching). Refactor every G-173
helper to the dict-create + dict-set accumulator and json::write
accumulator idioms so no proc body uses a trailing \. A stray continuation
introduced at line 6752 (return [punk::path_machine_return ...]) is replaced
with an lappend accumulator + expand. The user's doc-block edits convert the
punk::path punk::args::define block from \ continuations to -& record-
continuation markers with manually wrapped help text (table-width safe).

Add scriptlib/developer/linecont_lint.tcl, a linter that flags
\<newline> continuations outside punk::args doc blocks. It resolves a real
Tcl parser (the C tclparser library, or punk::lib's pure-Tcl punk::tclparser
fallback) and locates doc-block spans structurally (punk::args::define brace
arg, lappend PUNKARGS command range) so the exemption is not a regex guess;
comment-line trailing \ is also exempt. Sourceable linecont_lint_run proc
with a script-mode guard; run as a script it exits 0 clean / 1 findings / 2
usage. 5 pinning tests in runner/testsuites/parser/linecontlint.test cover
proc-body flagging, punk::args::define exemption, lappend PUNKARGS
exemption, comment-line exemption, and a clean expand-style proc (pass Tcl
9 + 8.6).

src/modules/AGENTS.md: the -& record-continuation marker is now REQUIRED for
new and edited punk::args doc blocks (existing blocks may keep \ until
touched; editing a \ block requires converting to -& in the same change);
the closeout trailing-\ search is cross-referenced to the linter.

Assisted-by: harness=pi; primary-model=huggingface/zai-org/GLM-5.2; api-location=huggingface.co
master
Julian Noble 3 days ago
parent
commit
f945bb12ca
  1. 255
      scriptlib/developer/linecont_lint.tcl
  2. 4
      src/modules/AGENTS.md
  3. 195
      src/modules/punk-999999.0a1.0.tm
  4. 113
      src/tests/runner/testsuites/parser/linecontlint.test

255
scriptlib/developer/linecont_lint.tcl

@ -0,0 +1,255 @@
#!/usr/bin/env tclsh
# linecont_lint.tcl - lint Tcl line-continuation backslashes outside doc blocks
#
# Per src/modules/AGENTS.md, line-continuation backslashes (\ at end of a line)
# must NOT appear in proc bodies or other executable code - they make debug
# line-number matching harder and the expand {dict create {*}...} / accumulator
# idioms are the required style. The ONLY allowed context is punk::args doc
# blocks (punk::args::define {...}, lappend PUNKARGS {...} / [list {...}]),
# where \ (legacy) or -& (preferred) record continuation is part of the
# definition dialect.
#
# This linter reports \ line-continuations in non-docblock contexts. It uses a
# real Tcl parser (the tclparser C library via 'package require parser', or the
# pure-Tcl punk::tclparser fallback wired by punk::lib::tclparser_prefer) to
# locate doc-block spans STRUCTURALLY (so the exemption is not a regex guess):
# the brace argument to punk::args::define, and the command range of
# 'lappend PUNKARGS ...'. Everything else - proc bodies, command lines,
# continuations inside [...] substitution, comments - is scanned for \newline
# and flagged. A \ inside a braced doc block is exempt; a \ inside a braced
# proc body is NOT (it is a real continuation when the body runs).
#
# Run under a punk environment so punk::lib can resolve the parser (the C lib
# ships in the punk9win kit; the pure-Tcl fallback is in src/modules/punk/tclparser):
# punk91 src script lib:developer/linecont_lint <path|glob|dir>...
# tclsh scriptlib/developer/linecont_lint.tcl <path|glob|dir>... (if parser present)
# Exit 0 when clean; exit 1 with one 'path:line: <msg>' line per finding; exit 2
# on usage error or if no parser is available.
#
# Limitations (v1):
# - The 'lappend PUNKARGS ...' exemption is by whole command range (covers both
# 'lappend PUNKARGS {doc}' and 'lappend PUNKARGS [list {doc}]'); a \ on the
# same physical line as 'lappend PUNKARGS' but outside the doc would be
# missed - acceptable (doc authoring only).
# - A \ inside a braced DATA literal that is not a doc block (e.g. a multi-line
# braced string constant passed to a non-doc command) is flagged as a
# false positive - rare in practice; the expand idiom avoids it.
# - The parser's 'parse command' wrapper does not expose [...] sub-commands, so
# the linter cannot exempt a doc block nested inside [...] - but doc blocks
# are not authored that way, so this is a non-issue.
# --- parser bootstrap ------------------------------------------------------
set parse_cmd ""
if {![catch {package require parser}]} {
set parse_cmd ::parse
} elseif {![catch {package require punk::lib}]} {
catch {punk::lib::tclparser_prefer c}
if {[llength [info commands ::punk::lib::parse]]} {
set parse_cmd ::punk::lib::parse
} else {
catch {punk::lib::tclparser_prefer tcl}
if {[llength [info commands ::punk::lib::parse]]} {
set parse_cmd ::punk::lib::parse
}
}
}
if {$parse_cmd eq ""} {
puts stderr "linecont_lint: no Tcl parser available - need 'package require parser' (C tclparser) or punk::lib (pure-Tcl fallback). Run via 'punk91 src script lib:developer/linecont_lint ...'."
exit 2
}
set script_body_cmds {proc apply lambda if elseif else while for foreach try catch eval uplevel namespace time}
proc is_doc_cmd {words} {
set cmd [lindex $words 0]
if {$cmd eq "punk::args::define" || $cmd eq "::punk::args::define"} { return 1 }
if {$cmd eq "lappend" || $cmd eq "::lappend"} {
if {[lindex $words 1] eq "PUNKARGS"} { return 2 }
}
return 0
}
proc is_script_body_cmd {cmd} {
variable script_body_cmds
expr {$cmd in $script_body_cmds || $cmd in [lmap c $script_body_cmds {list ::$c}]}
}
#collect exempt spans (doc blocks) into ::exempt, as [start end) offsets in the
#FILE text ($text). $script is the substring being parsed, $base its offset.
proc collect_doc_spans {text script base} {
#Parse a SUFFIX substring at each step (always {0 end}) and adjust offsets
#by +$pos. The parser's range-start argument is honoured in isolation but
#misbehaves mid-string around backslash-continuations; feeding a suffix
#avoids that fragility entirely. Token offsets returned by the parser are
#relative to $suffix; word extraction uses $suffix, brace detection and
#body extraction shift by +$pos into the $script frame.
set pos 0
set slen [string length $script]
set iter 0
while {$pos < $slen} {
incr iter
if {$iter > 500000} { puts stderr "linecont_lint: bailed (iteration cap) collecting doc spans at base $base"; return }
set suffix [string range $script $pos end]
set rc [catch {$::parse_cmd command $suffix {0 end}} res]
if {$rc} { return }
lassign $res commentRange commandRange remainderRange tokens
set cstart [expr {[lindex $commandRange 0] + $pos}]
set clen [lindex $commandRange 1]
#comment span (covers \ in a comment line - harmless comment continuation,
#not a command continuation): record as exempt. Present for both
#comment-only (clen 0) and command-with-leading-comment (clen>0) parses.
set cmt_start [expr {[lindex $commentRange 0] + $pos}]
set cmt_len [lindex $commentRange 1]
if {$cmt_len > 0} {
lappend ::exempt [list [expr {$base + $cmt_start}] [expr {$base + $cmt_start + $cmt_len}]]
}
if {$clen == 0} {
set adv [expr {[lindex $commentRange 0] + [lindex $commentRange 1] + $pos}]
if {$adv <= $pos} { incr adv }
set pos $adv
continue
}
set cend [expr {$cstart + $clen}]
if {$cend <= $pos} { set pos [expr {max($cend, $pos) + 1}]; continue }
#word strings (tokens are suffix-relative -> use $suffix)
set words [list]
foreach tok $tokens {
lappend words [token_word $suffix $tok]
}
set doc [is_doc_cmd $words]
if {$doc == 1} {
set tokidx 0
foreach tok $tokens {
if {$tokidx == 0} { incr tokidx; continue }
lassign [token_outer $tok] sost solen
set ostart [expr {$sost + $pos}]
set olen $solen
if {$olen >= 2 && [string index $script $ostart] eq "\{"} {
lappend ::exempt [list [expr {$base + $ostart}] [expr {$base + $ostart + $olen}]]
}
incr tokidx
}
} elseif {$doc == 2} {
lappend ::exempt [list [expr {$base + $cstart}] [expr {$base + $cend}]]
}
#recurse into script-body braces to find nested doc blocks (e.g. a
#punk::args::define inside a namespace eval argdoc inside a proc).
set cmd [lindex $words 0]
if {($cmd eq "dict" || $cmd eq "::dict") && [lindex $words 1] in {update with}} {
set last [lindex $tokens end]
lassign [token_outer $last] sost solen
set ostart [expr {$sost + $pos}]
set olen $solen
if {$olen >= 2 && [string index $script $ostart] eq "\{"} {
set body [string range $script [expr {$ostart+1}] [expr {$ostart+$olen-2}]]
collect_doc_spans $text $body [expr {$base + $ostart + 1}]
}
} elseif {[is_script_body_cmd $cmd]} {
foreach tok [lrange $tokens 1 end] {
lassign [token_outer $tok] sost solen
set ostart [expr {$sost + $pos}]
set olen $solen
if {$olen >= 2 && [string index $script $ostart] eq "\{"} {
set body [string range $script [expr {$ostart+1}] [expr {$ostart+$olen-2}]]
collect_doc_spans $text $body [expr {$base + $ostart + 1}]
}
}
}
set pos $cend
}
}
#token helpers. tok: {simple {outerStart outerLen} {{text {innerStart innerLen} {value}} ...}}
proc token_outer {tok} {
set r [lindex $tok 1]
return [list [lindex $r 0] [lindex $r 1]]
}
proc token_word {script tok} {
if {[llength $tok] < 3} { return "" }
set firstsub [lindex [lindex $tok 2] 0]
set irange [lindex $firstsub 1]
set istart [lindex $irange 0]
set ilen [lindex $irange 1]
return [string range $script $istart [expr {$istart+$ilen-1}]]
}
proc offset_to_line {text off} {
set n 1
for {set i 0} {$i < $off && $i < [string length $text]} {incr i} {
if {[string index $text $i] eq "\n"} { incr n }
}
return $n
}
proc in_exempt {off} {
foreach sp $::exempt {
if {$off >= [lindex $sp 0] && $off < [lindex $sp 1]} { return 1 }
}
return 0
}
set findings [list]
proc flag {path line msg} { lappend ::findings "$path:$line: $msg" }
# --- file collection -------------------------------------------------------
proc collect_files {args} {
set files [list]
foreach a $args {
if {[string match {*[\[\?\*]*} $a]} {
foreach f [glob -nocomplain -type f $a] { lappend files $f }
} elseif {[file isfile $a]} {
lappend files $a
} elseif {[file isdir $a]} {
foreach f [glob -nocomplain -type f -directory $a -- *.tcl *.tm] { lappend files $f }
}
}
return $files
}
# --- main: sourceable proc + script-mode guard ----------------------------
# linecont_lint_run <files...> -> list of finding strings (empty if clean).
# Sourceable: when this file is sourced (not run as a script), the proc is
# defined and nothing runs; callers invoke it and read the result. When run as a
# script, the trailing guard invokes it and exits with 0/1/2.
proc linecont_lint_run {files} {
set ::exempt [list]
set ::findings [list]
foreach f $files {
set fd [open $f r]
set text [read $fd]
close $fd
set ::exempt [list]
collect_doc_spans $text $text 0
set tlen [string length $text]
for {set i 0} {$i < $tlen} {incr i} {
if {[string index $text $i] ne "\\"} continue
set nx [string index $text [expr {$i+1}]]
if {$nx ne "\n" && $nx ne "\r"} continue
if {![in_exempt $i]} {
flag $f [offset_to_line $text $i] "line-continuation backslash outside a punk::args doc block (use expand {dict create {*}...} / accumulators, or -& in a doc block, per src/modules/AGENTS.md)"
}
incr i
}
}
return $::findings
}
#script-mode only: when this file is the invoked script, parse argv and exit.
if {[info exists ::argv0] && [file normalize $::argv0] eq [file normalize [info script]]} {
if {$argc == 0} {
puts stderr "usage: $argv0 <path|glob|dir>...
lints Tcl .tcl/.tm files for line-continuation backslashes outside punk::args doc blocks"
exit 2
}
set files [collect_files {*}$argv]
if {![llength $files]} {
puts stderr "linecont_lint: no .tcl/.tm files matched: $argv"
exit 2
}
set findings [linecont_lint_run $files]
if {[llength $findings]} {
foreach fl $findings { puts $fl }
puts stderr "linecont_lint: [llength $findings] finding(s) across [llength $files] file(s)"
exit 1
}
puts stderr "linecont_lint: clean ([llength $files] file(s) checked)"
exit 0
}

4
src/modules/AGENTS.md

@ -58,7 +58,7 @@ Source of truth for all editable Punk project modules. This is where agents shou
- For mixed literal and substituted values where element order does not matter, group reorderable literal values into one expanded braced literal and put substituted values on aligned continuation lines.
- When substituted values or nested commands require Tcl expand-style continuation inside one command argument list, vertically align each trailing `{*}{` marker in that list.
- When entries in a many-argument command need inline documentation, an empty command substitution marker such as `{*}[ ... ]` may be used between arguments so comments can live inside the command. Comments may be on their own lines or at the end of marker lines. Keep only whitespace and comments inside those square brackets; do not put executable Tcl there. Align trailing `{*}[` markers the same way as trailing `{*}{` markers.
- Before closeout for `.tcl` or `.tm` edits that add or alter proc bodies, search modified files for trailing `\` and confirm every remaining hit is inside allowed `punk::args` documentation text.
- Before closeout for `.tcl` or `.tm` edits that add or alter proc bodies, search modified files for trailing `\` and confirm every remaining hit is inside allowed `punk::args` documentation text. The linter `scriptlib/developer/linecont_lint.tcl` automates this: run `punk91 src script lib:developer/linecont_lint <path|glob|dir>` (or `tclsh scriptlib/developer/linecont_lint.tcl <...>` under a shell with the C `parser` package) - it flags `\<newline>` continuations outside `punk::args` doc blocks and exempts doc blocks (both `punk::args::define` and `lappend PUNKARGS`) and trailing-`\` comment lines. A clean run exits 0.
- For test-specific continuation rules, use `src/tests/AGENTS.md`.
- Keep pipelines readable by aligning `% var = ...` and `pipecase` segments when practical.
- Keep inline comments concise and describe intent, not mechanics.
@ -114,7 +114,7 @@ dict create {*}[
#- comments such as this with a leading dash are descriptions for the agent of how-to/what-to to implement
#- whereas comments without the dash are either literal comments, or if angle-bracketed, descriptions of what to put in the comment.
#- within the list of documentation blocks a trailing -& is a record continuation marker.
#- The -& record-continuation marker is preferred for new work. Existing blocks may use the Tcl line-continuation mechanism instead.
#- The -& record-continuation marker is REQUIRED for new and edited doc blocks; existing blocks may keep the Tcl line-continuation mechanism until touched. When editing a block that uses trailing \ continuations, convert to -& in the same change.
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::full::namespace::path::procedure_name

195
src/modules/punk-999999.0a1.0.tm

@ -6403,48 +6403,52 @@ namespace eval punk {
namespace eval argdoc {
punk::args::define {
@id -id ::punk::path
@cmd -name "punk::path"\
-summary\
"Display PATH executable shadowing and conflicts with TCL commands"\
-help\
@cmd -name "punk::path" -&
-summary -&
"Display PATH executable shadowing and conflicts with TCL commands" -&
-help -&
{Introspection of the PATH environment variable.
This tool will examine executables within each PATH entry and show which binaries
are overshadowed by earlier PATH entries.
It can also be used to examine the contents of each PATH entry, and to filter results using glob patterns.
${[punk::args::helpers::example {
#show all executables in all PATH entries
punk::path
#show all executables in all PATH entries that contain 'Windows' in the path
punk::path -pathglob *Windows*
punk::path -pathglob *Windows*
#show all executables in all PATH entries that contain 'scoop' in the path,
#and filter the executables to show only those that are named dir, ls or start with 'ca'
punk::path -pathglob *scoop* dir ls ca*
#show all executables that conflict with TCL commands starting with 'a' in the current namespace.
#show all executables that conflict with TCL commands starting with 'a' in the current namespace.
punk::path {*}[nscommandlist a*]
#show all executables that conflict with TCL commands resolvable from the current namespace.
punk::path {*}[info commands]
}]}
see also the punk::auto_exec package.
see also the punk::auto_exec package.
}
@opts
-pathglob -type string -default {*} -multiple true -help "Case insensitive glob pattern to filter path entries. Default '*' to include all PATH directories."
-return -type string -default table -choices {table text dict json} -help\
"Return form of the results. 'table' (default) is the human ANSI table.\
'text' is a plain-ASCII fixed-key layout (no ANSI, no table layout) for pipes/LLMs.\
'dict' returns a Tcl dict of the structured data.\
'json' returns a JSON string (via tcllib json::write) of the same structure.\
The text/dict/json forms contain no ANSI escapes regardless of other options."
-context -type string -default {} -help\
"Namespace whose commands are checked for conflicts with PATH executables.\
Default empty = the caller's namespace (the historical behaviour). Naming a namespace explicitly makes the conflict verdict stable for a machine consumer invoked through a wrapper."
-conflicts -type boolean -default 1 -help\
"Whether to compute TCL-context conflicts. Set 0 to skip (a small perf win); the dict/json/text conflict fields are then empty."
-pathglob -type string -default {*} -multiple true -help -&
"Case insensitive glob pattern to filter path entries. Default '*' to include all PATH directories."
-return -type string -default table -choices {table text dict json} -help -&
"Return form of the results. 'table' (default) is the human ANSI table.
'text' is a plain-ASCII fixed-key layout (no ANSI, no table layout) for pipes/LLMs.
'dict' returns a Tcl dict of the structured data.
'json' returns a JSON string (via tcllib json::write) of the same structure.
The text/dict/json forms contain no ANSI escapes regardless of other options."
-context -type string -default {} -help -&
"Namespace whose commands are checked for conflicts with PATH executables.
Default empty = the caller's namespace (the historical behaviour).
Naming a namespace explicitly makes the conflict verdict stable for a machine consumer invoked through a wrapper."
-conflicts -type boolean -default 1 -help -&
"Whether to compute TCL-context conflicts.
Set 0 to skip (a small perf win); the dict/json/text conflict fields are then empty."
@values -min 0 -max -1
binglob -type list -default {*} -multiple true -optional 1 -help "glob pattern to filter results. Default '*' to include all entries."
binglob -type list -default {*} -multiple true -optional 1 -help -&
"glob pattern to filter results. Default '*' to include all entries."
}
}
@ -6745,10 +6749,11 @@ namespace eval punk {
} else {
set nscaller [uplevel 1 {::tcl::namespace::current}]
}
return [punk::path_machine_return $returnmode $is_windows $sep \
[llength $pathglobs] $matched_paths $binglobs \
$d_path_info $d_bin_info $d_index_executables $all_paths \
$nscaller $do_conflicts]
set machine_args [list $returnmode $is_windows $sep]
lappend machine_args {*}[list [llength $pathglobs] $matched_paths $binglobs]
lappend machine_args {*}[list $d_path_info $d_bin_info $d_index_executables $all_paths]
lappend machine_args $nscaller $do_conflicts
return [punk::path_machine_return {*}$machine_args]
}
set nscaller [uplevel 1 {::tcl::namespace::current}]
@ -6998,18 +7003,17 @@ namespace eval punk {
}
#G-173: structured renderers for punk::path -return text/dict/json. The
#G-173: structured renderers for punk::path -return text/dict/json. The
#collect phase above (d_path_info/d_bin_info/d_index_executables) is shared
#with the table render; these procs walk the same dicts into an ordered entry
#list and serialise without ANSI/textblock. Conflict verdicts use a clean
#exact|nocase classification independent of the table render's ANSI path.
#Body style: no line-continuation backslashes (src/modules/AGENTS.md); dict
#literals use the expand {dict create {*}...} form, json::write builds via
#accumulators so no single call needs continuation.
#per-executable conflict set: returns a list of {command match} pairs where
#command is the resolved (namespace origin) name and match is exact|nocase.
#mirrors the table render's two-stage lookup (rootname first, then full name)
#but classifies case-correctly instead of feeding lsearch indices into a ne
#"" test (the historical table path never actually marks red; the structured
#path reports the real verdict).
proc path_conflicts_for_exe {exe context_commands nscaller is_windows} {
set conflicts [list]
set probe_names [list [file rootname $exe] $exe]
@ -7020,7 +7024,6 @@ namespace eval punk {
set ncmatches [lsearch -all -inline -exact $context_commands $probe]
}
if {![llength $ncmatches]} { continue }
#once we have matches on the rootname probe we stop (matches the table order)
set exact [expr {$probe in $context_commands}]
foreach nc $ncmatches {
set resolved [catch {namespace eval $nscaller [list namespace origin $nc]} origin]
@ -7047,12 +7050,6 @@ namespace eval punk {
set context_commands [list]
}
set entries [list]
set has_star [expr {[lsearch -exact $binglobs "*"] >= 0}]
foreach p $all_paths {
set pidx [llength $entries]
#note: pidx here is the position in all_paths, tracked separately below via the dict indices
}
#walk in original PATH order; pidx is the original PATH index
set pidx 0
foreach p $all_paths {
if {$is_windows} {
@ -7074,18 +7071,15 @@ namespace eval punk {
set overshadowed_count 0
if {$is_duplicate} {
#a duplicate PATH entry has no own executable list in the
#collected dicts (only the first occurrence is globbed). The
#table render reports the original's exe count but hides the
#exe display for duplicates; mirror that: exe_count from the
#original, executables list empty.
#collected dicts (only the first occurrence is globbed). Mirror
#the table column: exe_count from the original, executables empty.
set original_idx [lindex $indices 0]
if {[dict exists $d_index_executables $original_idx]} {
set exe_count_src [llength [dict get $d_index_executables $original_idx]]
set exe_count [llength [dict get $d_index_executables $original_idx]]
} else {
set exe_count_src 0
set exe_count 0
}
set executables [list]
set exe_count $exe_count_src
} else {
if {[dict exists $d_index_executables $pidx]} {
set executables [dict get $d_index_executables $pidx]
@ -7126,16 +7120,15 @@ namespace eval punk {
}
lappend exe_record_list [dict create name $exe overshadowed $overshadowed overshadowed_by $overshadowed_by tcl_conflicts $tcl_conflicts]
}
set entry [dict create \
idx $pidx \
path $p \
normalised $pnorm \
is_directory [file isdirectory $p] \
is_duplicate $is_duplicate \
duplicate_of $duplicate_of \
exe_count $exe_count \
overshadowed_count $overshadowed_count \
executables $exe_record_list]
set entry [dict create idx $pidx]
dict set entry path $p
dict set entry normalised $pnorm
dict set entry is_directory [file isdirectory $p]
dict set entry is_duplicate $is_duplicate
dict set entry duplicate_of $duplicate_of
dict set entry exe_count $exe_count
dict set entry overshadowed_count $overshadowed_count
dict set entry executables $exe_record_list
lappend entries $entry
incr pidx
}
@ -7153,32 +7146,27 @@ namespace eval punk {
foreach e $entries {
if {[dict get $e path] in $matched_paths} { lappend filtered $e }
}
} elseif {$has_star} {
set filtered $entries
} else {
if {$has_star} {
set filtered $entries
} else {
foreach e $entries {
if {[llength [dict get $e executables]] > 0} { lappend filtered $e }
}
foreach e $entries {
if {[llength [dict get $e executables]] > 0} { lappend filtered $e }
}
}
if {$returnmode eq "dict"} {
set summary [dict create \
context $nscaller \
is_windows $is_windows \
separator $sep \
path_entries [llength $all_paths] \
shown_entries [llength $filtered] \
executable_total [path_count_executables $filtered] \
overshadowed_total [path_count_overshadowed $filtered] \
conflict_total [path_count_conflicts $filtered]]
set summary [dict create context $nscaller]
dict set summary is_windows $is_windows
dict set summary separator $sep
dict set summary path_entries [llength $all_paths]
dict set summary shown_entries [llength $filtered]
dict set summary executable_total [path_count_executables $filtered]
dict set summary overshadowed_total [path_count_overshadowed $filtered]
dict set summary conflict_total [path_count_conflicts $filtered]
return [dict create summary $summary entries $filtered]
} elseif {$returnmode eq "json"} {
return [path_render_json $is_windows $sep $nscaller $all_paths $filtered]
} else {
#text
return [path_render_text $is_windows $sep $nscaller $all_paths $filtered]
}
return [path_render_text $is_windows $sep $nscaller $all_paths $filtered]
}
proc path_count_executables {entries} {
@ -7203,32 +7191,18 @@ namespace eval punk {
proc path_render_text {is_windows sep nscaller all_paths entries} {
set lines [list]
append hdr "=== punk::path context=" $nscaller " is_windows=" $is_windows " sep=" $sep
lappend lines $hdr
append sum "summary paths=" [llength $all_paths] " shown=" [llength $entries] \
" executables=" [path_count_executables $entries] \
" overshadowed=" [path_count_overshadowed $entries] \
" conflicts=" [path_count_conflicts $entries]
lappend lines $sum
lappend lines "=== punk::path context=$nscaller is_windows=$is_windows sep=$sep"
lappend lines "summary paths=[llength $all_paths] shown=[llength $entries] executables=[path_count_executables $entries] overshadowed=[path_count_overshadowed $entries] conflicts=[path_count_conflicts $entries]"
foreach e $entries {
set dup [dict get $e is_duplicate]
set dupof [dict get $e duplicate_of]
append eline "--- " [dict get $e idx] " " [dict get $e path] \
" dir=" [dict get $e is_directory] \
" dup=" $dup " dupof=" [expr {$dup ? $dupof : -1}] \
" exes=" [dict get $e exe_count] \
" shadow=" [dict get $e overshadowed_count]
lappend lines $eline
lappend lines "--- [dict get $e idx] [dict get $e path] dir=[dict get $e is_directory] dup=$dup dupof=[expr {$dup ? $dupof : -1}] exes=[dict get $e exe_count] shadow=[dict get $e overshadowed_count]"
foreach exe [dict get $e executables] {
set exline " [dict get $exe name]"
append exline " ov=" [dict get $exe overshadowed] \
" ovby=" [dict get $exe overshadowed_by]
set cl [list]
foreach c [dict get $exe tcl_conflicts] {
lappend cl "[dict get $c command]:[dict get $c match]"
}
append exline " conflict=" [join $cl ","]
lappend lines $exline
lappend lines " [dict get $exe name] ov=[dict get $exe overshadowed] ovby=[dict get $exe overshadowed_by] conflict=[join $cl ,]"
}
}
return [join $lines \n]
@ -7237,51 +7211,28 @@ namespace eval punk {
#JSON via tcllib json::write (G-173 preferred encoder - see goals/G-173 and
#the runtests json_emit hardening that settled the choice). Lazy-required so
#the fast -discover-only boot (which loads punk::path) is not slowed by a
#tcllib load for a mode nobody has asked for.
#tcllib load for a mode nobody has asked for. Values are built into
#accumulator lists so each json::write call stands on one line (no
#line-continuation backslashes - src/modules/AGENTS.md).
proc path_render_json {is_windows sep nscaller all_paths entries} {
package require json::write
json::write indented 1
json::write aligned 0
set summary [json::write object \
context [json::write string $nscaller] \
is_windows [expr {$is_windows ? 1 : 0}] \
separator [json::write string $sep] \
path_entries [llength $all_paths] \
shown_entries [llength $entries] \
executable_total [path_count_executables $entries] \
overshadowed_total [path_count_overshadowed $entries] \
conflict_total [path_count_conflicts $entries]]
set summary [json::write object context [json::write string $nscaller] is_windows [expr {$is_windows ? 1 : 0}] separator [json::write string $sep] path_entries [llength $all_paths] shown_entries [llength $entries] executable_total [path_count_executables $entries] overshadowed_total [path_count_overshadowed $entries] conflict_total [path_count_conflicts $entries]]
set entry_parts [list]
foreach e $entries {
set exe_parts [list]
foreach exe [dict get $e executables] {
set conf_parts [list]
foreach c [dict get $exe tcl_conflicts] {
lappend conf_parts [json::write object \
command [json::write string [dict get $c command]] \
match [json::write string [dict get $c match]]]
}
lappend exe_parts [json::write object \
name [json::write string [dict get $exe name]] \
overshadowed [expr {[dict get $exe overshadowed] ? 1 : 0}] \
overshadowed_by [dict get $exe overshadowed_by] \
tcl_conflicts [json::write array {*}$conf_parts]]
}
lappend entry_parts [json::write object \
idx [dict get $e idx] \
path [json::write string [dict get $e path]] \
normalised [json::write string [dict get $e normalised]] \
is_directory [expr {[dict get $e is_directory] ? 1 : 0}] \
is_duplicate [expr {[dict get $e is_duplicate] ? 1 : 0}] \
duplicate_of [dict get $e duplicate_of] \
exe_count [dict get $e exe_count] \
overshadowed_count [dict get $e overshadowed_count] \
executables [json::write array {*}$exe_parts]]
lappend conf_parts [json::write object command [json::write string [dict get $c command]] match [json::write string [dict get $c match]]]
}
lappend exe_parts [json::write object name [json::write string [dict get $exe name]] overshadowed [expr {[dict get $exe overshadowed] ? 1 : 0}] overshadowed_by [dict get $exe overshadowed_by] tcl_conflicts [json::write array {*}$conf_parts]]
}
lappend entry_parts [json::write object idx [dict get $e idx] path [json::write string [dict get $e path]] normalised [json::write string [dict get $e normalised]] is_directory [expr {[dict get $e is_directory] ? 1 : 0}] is_duplicate [expr {[dict get $e is_duplicate] ? 1 : 0}] duplicate_of [dict get $e duplicate_of] exe_count [dict get $e exe_count] overshadowed_count [dict get $e overshadowed_count] executables [json::write array {*}$exe_parts]]
}
return [json::write object summary $summary entries [json::write array {*}$entry_parts]]
}
#-------------------------------------------------------------------
#sh 'test' equivalent - to be used with exitcode of process
#

113
src/tests/runner/testsuites/parser/linecontlint.test

@ -0,0 +1,113 @@
# -*- tcl -*-
# Unit suite for scriptlib/developer/linecont_lint.tcl - the line-continuation
# linter that flags \ line-continuations outside punk::args doc blocks. Pins
# the behaviours that matter: a \ in a proc body is flagged; a \ inside a
# punk::args doc block (punk::args::define / lappend PUNKARGS) is exempt; a \ at
# the end of a comment line is exempt (harmless comment continuation).
#
# Fixture bodies are built with string map from placeholder templates because
# Tcl braces JOIN backslash-newline into a space (the lexer processes \<nl>
# even inside braces), so a braced literal cannot carry a real line-continuation
# backslash. Placeholders ~BS~ ~NL~ ~OB~ ~CB~ ~OBK~ ~CBK~ stand in for
# \ newline { } [ ] so the template stays balanced and the mapped body carries
# genuine \<nl> continuations. The ~ delim is chosen so adjacent placeholders
# (~OB~~CB~) and prefix-overlap pairs (~OB~ vs ~OBK~) do not collide under
# string map's left-to-right longest-first scan.
#
# The linter is SOURCED into the test interp (so punk::lib's pure-Tcl tclparser
# fallback resolves via the test module path, no child-exec parser bootstrap);
# the suite is capability-gated on the linter resolving a parser.
#
# Run: tclsh src/tests/runtests.tcl -report compact -show-passes 0 -include-paths runner/testsuites/parser linecontlint.test
package require tcltest
set _lcl_root [file dirname [file dirname [file dirname [file dirname [file dirname [file dirname [file normalize [info script]]]]]]]]
set _lcl_script [file join $_lcl_root scriptlib developer linecont_lint.tcl]
#source the linter (defines linecont_lint_run + resolves ::parse_cmd via
#package require parser or punk::lib's pure-Tcl fallback).
source $_lcl_script
#the linter sets ::parse_cmd to the resolved 'parse command' implementation, or
#"" if none resolved. Capability-gate on that.
tcltest::testConstraint linecontLintCapable [expr {$::parse_cmd ne ""}]
namespace eval ::testspace {
namespace import ::tcltest::*
proc mkbody {tpl} {
string map [list ~BS~ "\\" ~NL~ "\n" ~OB~ "{" ~CB~ "}" ~OBK~ "\[" ~CBK~ "\]"] $tpl
}
proc write_fixture {body} {
set fixture [file join [tcltest::temporaryDirectory] lcl_fixture_[clock clicks].tcl]
set fd [open $fixture w]
fconfigure $fd -translation lf
puts -nonewline $fd $body
close $fd
return $fixture
}
#added 2026-08-07 (agent - linecont_lint: line-continuation linter for non-docblock contexts)
test linecontlint-1.0 {backslash continuation in a proc body is flagged} -constraints linecontLintCapable -body {
set f [write_fixture [mkbody {proc bad ~OB~~CB~ ~OB~
set entry ~OBK~dict create ~BS~~NL~ idx 1 ~BS~~NL~ path /x~CBK~
~CB~
}]]
set out [linecont_lint_run [list $f]]
set facts [list]
lappend facts [regexp {line-continuation backslash outside a punk::args doc block} $out]
lappend facts [llength [lsearch -all -inline -regexp $out {:[0-9]+: line-continuation}]]
set facts
} -result {1 2}
#added 2026-08-07 (agent - linecont_lint)
test linecontlint-1.1 {backslash inside a punk::args::define doc block is exempt} -constraints linecontLintCapable -body {
set f [write_fixture [mkbody {namespace eval argdoc ~OB~
punk::args::define ~OB~
@id -id ::foo
@cmd -name "foo" ~BS~~NL~ -summary "a thing"
~CB~
~CB~
}]]
expr {[llength [linecont_lint_run [list $f]]] == 0}
} -result 1
#added 2026-08-07 (agent - linecont_lint)
test linecontlint-1.2 {backslash inside an lappend PUNKARGS doc block is exempt} -constraints linecontLintCapable -body {
set f [write_fixture [mkbody {namespace eval argdoc ~OB~
lappend PUNKARGS ~OBK~list ~OB~
@id -id ::foo
@cmd -name "foo" ~BS~~NL~ -summary "a thing"
~CB~~CBK~
~CB~
}]]
expr {[llength [linecont_lint_run [list $f]]] == 0}
} -result 1
#added 2026-08-07 (agent - linecont_lint)
test linecontlint-1.3 {backslash at the end of a comment line is exempt} -constraints linecontLintCapable -body {
set f [write_fixture [mkbody {proc ok ~OB~~CB~ ~OB~
#will do nothing if already prefixed with ~BS~~BS~?~BS~~NL~ set x 1
~CB~
}]]
expr {[llength [linecont_lint_run [list $f]]] == 0}
} -result 1
#added 2026-08-07 (agent - linecont_lint)
test linecontlint-1.4 {clean proc with expand-style dict create reports no findings} -constraints linecontLintCapable -body {
set f [write_fixture [mkbody {proc good ~OB~~CB~ ~OB~
set entry ~OBK~dict create idx 1~CBK~
dict set entry path /x
set s ~OBK~json::write object a ~OBK~json::write string x~CBK~ b ~OBK~json::write string y~CBK~~CBK~
~CB~
}]]
expr {[llength [linecont_lint_run [list $f]]] == 0}
} -result 1
cleanupTests
}
# Local Variables:
# mode: tcl
# End:
Loading…
Cancel
Save