Browse Source

punk::path 0.3.0: extensionless tcl scripts via lib: (first-line identification)

scriptlib_resolve now lets an extensionless (or unknown-extension) lib:<name>
call match a file named exactly <name> when its first lines identify it as
tcl: a leading '# tcl' comment (prefix, case-insensitive), a shebang naming
tclsh/tclkit/wish, or the portable sh-trampoline ('#!/bin/sh' with a
commented 'exec tclsh' continuation - detected within the first 4 lines).
New internal punk::path::system::scriptfile_is_tcl does the bounded sniff
(512 bytes, UTF-8 BOM and CRLF tolerated, directories/unreadable files
rejected). When both <name> and <name>.tcl exist, the spelling matching the
call wins, checked per scriptlib location so kit-internal directory
precedence is never crossed by a spelling preference.

.kit joins the known extensions: lib:<name>.kit previously had .tcl appended
(searched <name>.kit.tcl), which made the shell subcommand's do_tclkit
dispatch arms unreachable - lib: kit dispatch is now restored. Result dict
reworked: scripttype (tcl/kit/py/pl/ps1/sh or empty) + notes keys added,
scriptname is now the matched relative name, and matches require file isfile.

app-punkshell and app_shellrun lib: handling refactored onto the shared
resolver (previously independent inline copies of the policy), fixing two
silent-failure bugs in that copy: no message at all when a scriptlib dir
existed but lacked the script, and the error listing showing
<kit>/scriptlib rather than the <kit>/app/scriptlib actually checked.
app-punkscript's post-resolve gate now uses scripttype and its not-found
report includes the identification skip notes. The unused has_globchars
vestige is dropped.

Fixtures scriptlib/_punktest/{extless,dual,dual.tcl,shebang_sh,notatcl} and
5 new scriptexec.test cases (extensionless resolve, both precedence
directions, trampoline shebang + argv, non-tcl skip note on not-found);
suite passes 17/17 against a freshly built punk902z kit. Sniff helper unit
checks pass under tclsh 9.0.3 and 8.6. punkshell project 0.13.0 -> 0.14.0.

Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com
master
Julian Noble 3 days ago
parent
commit
1eb9dc690b
  1. 4
      CHANGELOG.md
  2. 2
      punkproject.toml
  3. 10
      scriptlib/_punktest/README.md
  4. 8
      scriptlib/_punktest/dual
  5. 5
      scriptlib/_punktest/dual.tcl
  6. 8
      scriptlib/_punktest/extless
  7. 7
      scriptlib/_punktest/notatcl
  8. 10
      scriptlib/_punktest/shebang_sh
  9. 2
      src/lib/AGENTS.md
  10. 10
      src/lib/app-punkscript/punkscript.tcl
  11. 105
      src/lib/app-punkshell/punkshell.tcl
  12. 105
      src/lib/app_shellrun/app_shellrun.tcl
  13. 141
      src/modules/punk/path-999999.0a1.0.tm
  14. 3
      src/modules/punk/path-buildversion.txt
  15. 2
      src/tests/shell/AGENTS.md
  16. 59
      src/tests/shell/testsuites/punkexe/scriptexec.test

4
CHANGELOG.md

@ -5,6 +5,10 @@ The latest `## [X.Y.Z]` header must match the `version` field in `punkproject.to
Entries are newest-first; one bullet per notable change. See the root `AGENTS.md`
"Project Versioning" section for the bump policy.
## [0.14.0] - 2026-07-18
- `lib:` scriptlib scripts may now be extensionless: an extensionless (or unknown-extension) `lib:<name>` call also matches a file named exactly `<name>` when its first lines identify it as tcl - a leading `# tcl` comment, a shebang naming tclsh/tclkit/wish, or the portable sh-trampoline (`#!/bin/sh` with a commented `exec tclsh` continuation). When both `<name>` and `<name>.tcl` exist, the spelling matching the call wins, checked per scriptlib location so kit-internal precedence is unaffected. Implemented in punk::path 0.3.0 (`scriptlib_resolve` + new internal `scriptfile_is_tcl`; result dict gains `scripttype`/`notes`, not-found errors list skip notes); `.kit` is now a known extension so `lib:<name>.kit` resolves as itself (previously it wrongly searched `<name>.kit.tcl`, making the shell subcommand's kit dispatch unreachable - now restored). The `shell` subcommand's `lib:` handling (app-punkshell, app_shellrun) is refactored onto the shared resolver, fixing two silent-failure bugs in its former inline copy (no message when a scriptlib dir existed but lacked the script; wrong kit-internal path in the error listing). Test fixtures + coverage added under `scriptlib/_punktest/` and `src/tests/shell/testsuites/punkexe/scriptexec.test`.
## [0.13.0] - 2026-07-18
- punk::ns 0.9.0: cmdhelp (`i`) gains `-return text` - plain-text argument documentation for machine/LLM consumption: no ANSI codes, no table layout, and the argument section of every command form rendered in a single output under a common header (`i -return text after` shows all six forms), with G-041 form selection still honoured when argument words are supplied. No advisory-parse error preamble (use `-return dict` for parse status).

2
punkproject.toml

@ -1,4 +1,4 @@
[project]
name = "punkshell"
version = "0.13.0"
version = "0.14.0"
license = "BSD-2-Clause"

10
scriptlib/_punktest/README.md

@ -16,6 +16,16 @@ mechanism and the `script` subcommand's Tk main-loop behaviour against
Fixtures:
- `echo.tcl` — emits a marker plus its `::argv0`/`::argv`; used for `lib:`
resolution, subpath resolution, and file-form argument passing.
- `extless` — extensionless, first line `# tcl`; proves extensionless-tcl
matching (punk::path 0.3.0 first-line identification).
- `dual` / `dual.tcl` — precedence pair with distinct markers; the spelling
matching the call must win (`lib:_punktest/dual` runs `dual`,
`lib:_punktest/dual.tcl` runs `dual.tcl`).
- `shebang_sh` — extensionless, portable sh-trampoline shebang (`#!/bin/sh`
with commented `exec tclsh` continuation); proves the identification check
accepts the classic cross-platform idiom.
- `notatcl` — extensionless with a python shebang and no `.tcl` sibling; must
NOT resolve, and the not-found output must note the identification skip.
- `tk_countdown.tcl <ms>` — Tk window that exits 0 from an `after` callback;
proves the `script` subcommand services a registered Tk main loop (the
callback runs only if the event loop is serviced).

8
scriptlib/_punktest/dual

@ -0,0 +1,8 @@
# tcl
#PUNKTEST FIXTURE - owned by src/tests/shell/testsuites/punkexe/scriptexec.test
#Do not hand-edit: the suite asserts on the exact marker/output below.
#Precedence fixture pair with dual.tcl: an extensionless call ('lib:_punktest/dual')
#must select THIS file; the explicit call ('lib:_punktest/dual.tcl') must select
#the sibling. The markers differ so the suite can tell which one ran.
puts "PUNKTEST_DUAL=bare"
flush stdout

5
scriptlib/_punktest/dual.tcl

@ -0,0 +1,5 @@
#PUNKTEST FIXTURE - owned by src/tests/shell/testsuites/punkexe/scriptexec.test
#Do not hand-edit: the suite asserts on the exact marker/output below.
#Precedence fixture pair with the extensionless 'dual': see that file's header.
puts "PUNKTEST_DUAL=dottcl"
flush stdout

8
scriptlib/_punktest/extless

@ -0,0 +1,8 @@
# tcl
#PUNKTEST FIXTURE - owned by src/tests/shell/testsuites/punkexe/scriptexec.test
#Do not hand-edit: the suite asserts on the exact marker/output below.
#Extensionless fixture: first line is the '# tcl' identification marker so that
#'lib:_punktest/extless' resolves via punk::path::scriptlib_resolve's
#extensionless-tcl matching (there is no extless.tcl sibling).
puts "PUNKTEST_EXTLESS argv0=[file tail $::argv0] argv=$::argv"
flush stdout

7
scriptlib/_punktest/notatcl

@ -0,0 +1,7 @@
#!/usr/bin/env python
#PUNKTEST FIXTURE - owned by src/tests/shell/testsuites/punkexe/scriptexec.test
#Do not hand-edit: the suite asserts on the exact behaviour below.
#Extensionless NON-tcl fixture (python shebang, no .tcl sibling): an attempted
#'lib:_punktest/notatcl' must NOT resolve - the suite asserts a not-found failure
#whose output notes that this file was skipped by the tcl identification check.
print("PUNKTEST_NOTATCL should never execute via lib:")

10
scriptlib/_punktest/shebang_sh

@ -0,0 +1,10 @@
#!/bin/sh
# \
exec tclsh "$0" ${1+"$@"}
#PUNKTEST FIXTURE - owned by src/tests/shell/testsuites/punkexe/scriptexec.test
#Do not hand-edit: the suite asserts on the exact marker/output below.
#Extensionless fixture using the portable sh-trampoline shebang (line 1 names sh,
#not tcl - the commented 'exec tclsh' continuation identifies it): proves the
#scriptlib_resolve identification check accepts the classic cross-platform idiom.
puts "PUNKTEST_SHEBANG_SH argv0=[file tail $::argv0] argv=$::argv"
flush stdout

2
src/lib/AGENTS.md

@ -16,7 +16,7 @@ Source of truth for all editable `pkgIndex.tcl`-based library packages. These ar
- Libraries here include:
- `app-punk/` — Punk REPL app entry (`app-punk::repl`)
- `app-punkshell/` — Punkshell app entry (`app-punkshell`); on eof of its input channel the `env(PUNK_PIPE_EOF)` policy (`exit`|`interactive`|unset=console heuristic) decides between terminating and reopening the console for an interactive repl — automated callers should set `exit` or end piped input with an explicit `exit`
- `app-punkscript/` — lean one-shot script runner behind the punk executable `script` subcommand (G-015): runs a file, a `lib:<name>` scriptlib script (resolved via `punk::path::scriptlib_resolve`), or piped stdin in the default punk shell module/alias environment with honest exit codes; no shellfilter stacks or logging side effects, never falls into an interactive shell — the reliable path for automated/agent callers (prefer this over piping into `shell`); stdin form echoes the script's final result when non-empty; GUI parity with `tclsh` — a script leaving a registered Tk main loop is serviced until its window closes (so `script app.tcl` keeps a Tk app alive), while console scripts and errored scripts exit immediately
- `app-punkscript/` — lean one-shot script runner behind the punk executable `script` subcommand (G-015): runs a file, a `lib:<name>` scriptlib script (resolved via `punk::path::scriptlib_resolve`; an extensionless call also matches an extensionless file whose first lines identify it as tcl - `# tcl` comment or tcl shebang incl. the sh-trampoline - with the called spelling winning when both spellings exist), or piped stdin in the default punk shell module/alias environment with honest exit codes; no shellfilter stacks or logging side effects, never falls into an interactive shell — the reliable path for automated/agent callers (prefer this over piping into `shell`); stdin form echoes the script's final result when non-empty; GUI parity with `tclsh` — a script leaving a registered Tk main loop is serviced until its window closes (so `script app.tcl` keeps a Tk app alive), while console scripts and errored scripts exit immediately
- `app-shellspy/` — ShellSpy app entry (`app-shellspy`)
- `app_shell/` — Shell app helpers (`app_shell`)
- `app_shellrun/` — Shell run helpers (`app_shellrun`)

10
src/lib/app-punkscript/punkscript.tcl

@ -81,6 +81,9 @@ apply {{} {
#Resolution policy is shared with the shell subcommand's scheme via
#punk::path::scriptlib_resolve (vfs-internal scriptlib wins and is
#not externally overridable, then scriptlib dirs relative to the exe).
#An extensionless call may also match an extensionless tcl file whose
#first lines identify it as tcl ('# tcl' comment or tcl shebang);
#the spelling matching the call wins when both spellings exist.
set libname [string trimleft [string range $scriptname [string first : $scriptname]+1 end] :]
package require punk::path
if {[catch {punk::path::scriptlib_resolve $libname} resolved]} {
@ -93,10 +96,13 @@ apply {{} {
foreach c [dict get $resolved candidates] {
puts stderr " $c"
}
foreach n [dict get $resolved notes] {
puts stderr " note: $n"
}
exit 1
}
if {[string tolower [file extension $scriptpath]] ne ".tcl"} {
puts stderr "punk script: only .tcl scripts are supported via lib: in the script subcommand (resolved: $scriptpath)"
if {[dict get $resolved scripttype] ne "tcl"} {
puts stderr "punk script: only tcl scripts are supported via lib: in the script subcommand (resolved: $scriptpath)"
exit 1
}
set scriptpath [file normalize $scriptpath]

105
src/lib/app-punkshell/punkshell.tcl

@ -303,92 +303,41 @@ dict with prevglobal {}
switch -glob -nocase -- $script_or_kit {
lib:* {
set exitinfo {}
#scriptlib
#There may be one or more colons after lib
#scriptlib script: lib:<name> (one or more colons after lib), with or
#without .tcl extension - an extensionless call may also match an
#extensionless tcl file whose first lines identify it as tcl.
#Resolution policy is shared with the 'script' subcommand via
#punk::path::scriptlib_resolve (vfs-internal scriptlib wins and is
#not externally overridable, then scriptlib dirs relative to the exe).
set cposn [string first : $script_or_kit]
set script_or_kit [string trimleft [string range $script_or_kit $cposn+1 end] :]
if {[file pathtype $script_or_kit] eq "relative"} {
set has_globchars [regexp {[*?]} $script_or_kit] ;#basic globs only?
set exepath [file dirname [file normalize [file join [info nameofexecutable] ___]]] ;#symlink resolve - review should we resolve scriptlib relative to a symlink too?
set kit_libdir "" ;#metakit or zipkit libdir
set known_extensions [list .tcl .py .pl .ps1 .sh] ;#review
set ext [file extension $script_or_kit]
if {[string tolower $ext] ni $known_extensions} {
#only .tcl scripts allowed to be called extensionlessly
set scriptname $script_or_kit.tcl
} else {
set scriptname $script_or_kit
}
set lower_ext [string tolower [file extension $scriptname]]
if {$lower_ext in {.tcl .kit}} {
set has_zipfs_command [expr {[info commands ::tcl::zipfs::root] ne ""}]
set kit_base ""
if {$has_zipfs_command && [file exists [tcl::zipfs::root]]} {
set kit_base [tcl::zipfs::root]
} elseif {[file type $exepath] eq "directory"} {
set kit_base $exepath
set libname [string trimleft [string range $script_or_kit $cposn+1 end] :]
package require punk::path
if {[catch {punk::path::scriptlib_resolve $libname} resolved]} {
#covers the non-relative-path rejection
puts stderr "punkshell: $resolved"
} else {
set scriptpath [dict get $resolved path]
if {$scriptpath eq ""} {
puts stderr "script '[dict get $resolved scriptname]' not found in vfs or in scriptlib folders relative to the executable. Locations checked:"
foreach c [dict get $resolved candidates] {
puts stderr " $c"
}
if {$has_zipfs_command && [file exists $kit_base/app/scriptlib]} {
set kit_libdir $kit_base/app/scriptlib
} elseif {[file exists $exepath/scriptlib]} {
set kit_libdir $exepath/scriptlib
foreach n [dict get $resolved notes] {
puts stderr " note: $n"
}
#partly for performance benefit - we don't allow overriding of vfs internal scripts.
#Only additional scripts can be provided by the bin/scriptlib or ../bin/scriptlib folders
if {$kit_libdir ne "" && [file exists $kit_libdir/$scriptname]} {
switch -- $lower_ext {
.tcl {
set exitinfo [punkshell::do_script $kit_libdir/$scriptname {*}$arglist]
}
.kit {
set exitinfo [punkshell::do_tclkit $kit_libdir/$scriptname "no_repl" {*}$arglist]
}
}
} else {
#fallback to external filesystem
set exedir [file dirname $exepath]
set bin_scripts [file join $exedir scriptlib]
set binsibling_scripts [file join [file dirname $exedir] scriptlib]
set script_check_paths [list]
if {[file exists $bin_scripts]} {
lappend script_check_paths $bin_scripts/$scriptname
} else {
switch -- [dict get $resolved scripttype] {
tcl {
set exitinfo [punkshell::do_script $scriptpath {*}$arglist]
}
if {[file exists $binsibling_scripts]} {
lappend script_check_paths $binsibling_scripts/$scriptname
kit {
set exitinfo [punkshell::do_tclkit $scriptpath "no_repl" {*}$arglist]
}
if {[llength $script_check_paths]} {
foreach check_path $script_check_paths {
if {[file exists $check_path]} {
switch -- $lower_ext {
.tcl {
set exitinfo [punkshell::do_script $check_path {*}$arglist]
}
.kit {
set exitinfo [punkshell::do_tclkit $check_path "no_repl" {*}$arglist]
}
}
break
}
}
} else {
puts stderr "script $script_or_kit not found in vfs or in filesystem relative to $exedir"
puts stderr "valid locations:"
if {$kit_base ne ""} {
puts stderr " $kit_base/scriptlib/$scriptname"
}
puts stderr " $bin_scripts/$scriptname"
puts stderr " $binsibling_scripts/$scriptname"
default {
puts stderr "No current support for extension [file extension $scriptpath] via lib: (resolved: $scriptpath)"
}
}
} else {
puts stderr "No current support for extension [file extension $scriptname]"
}
} else {
puts stderr "Path supplied to lib: must be a relative path"
}
}
*.tcl {

105
src/lib/app_shellrun/app_shellrun.tcl

@ -250,92 +250,41 @@ dict with prevglobal {}
switch -glob -nocase -- $script_or_kit {
lib:* {
set exitinfo {}
#scriptlib
#There may be one or more colons after lib
#scriptlib script: lib:<name> (one or more colons after lib), with or
#without .tcl extension - an extensionless call may also match an
#extensionless tcl file whose first lines identify it as tcl.
#Resolution policy is shared with the 'script' subcommand via
#punk::path::scriptlib_resolve (vfs-internal scriptlib wins and is
#not externally overridable, then scriptlib dirs relative to the exe).
set cposn [string first : $script_or_kit]
set script_or_kit [string trimleft [string range $script_or_kit $cposn+1 end] :]
if {[file pathtype $script_or_kit] eq "relative"} {
set has_globchars [regexp {[*?]} $script_or_kit] ;#basic globs only?
set exepath [file dirname [file normalize [file join [info nameofexecutable] ___]]] ;#symlink resolve - review should we resolve scriptlib relative to a symlink too?
set kit_libdir "" ;#metakit or zipkit libdir
set known_extensions [list .tcl .py .pl .ps1 .sh] ;#review
set ext [file extension $script_or_kit]
if {[string tolower $ext] ni $known_extensions} {
#only .tcl scripts allowed to be called extensionlessly
set scriptname $script_or_kit.tcl
} else {
set scriptname $script_or_kit
}
set lower_ext [string tolower [file extension $scriptname]]
if {$lower_ext in {.tcl .kit}} {
set has_zipfs_command [expr {[info commands ::tcl::zipfs::root] ne ""}]
set kit_base ""
if {$has_zipfs_command && [file exists [tcl::zipfs::root]]} {
set kit_base [tcl::zipfs::root]
} elseif {[file type $exepath] eq "directory"} {
set kit_base $exepath
set libname [string trimleft [string range $script_or_kit $cposn+1 end] :]
package require punk::path
if {[catch {punk::path::scriptlib_resolve $libname} resolved]} {
#covers the non-relative-path rejection
puts stderr "punkshell: $resolved"
} else {
set scriptpath [dict get $resolved path]
if {$scriptpath eq ""} {
puts stderr "script '[dict get $resolved scriptname]' not found in vfs or in scriptlib folders relative to the executable. Locations checked:"
foreach c [dict get $resolved candidates] {
puts stderr " $c"
}
if {$has_zipfs_command && [file exists $kit_base/app/scriptlib]} {
set kit_libdir $kit_base/app/scriptlib
} elseif {[file exists $exepath/scriptlib]} {
set kit_libdir $exepath/scriptlib
foreach n [dict get $resolved notes] {
puts stderr " note: $n"
}
#partly for performance benefit - we don't allow overriding of vfs internal scripts.
#Only additional scripts can be provided by the bin/scriptlib or ../bin/scriptlib folders
if {$kit_libdir ne "" && [file exists $kit_libdir/$scriptname]} {
switch -- $lower_ext {
.tcl {
set exitinfo [punkshell::do_script $kit_libdir/$scriptname {*}$arglist]
}
.kit {
set exitinfo [punkshell::do_tclkit $kit_libdir/$scriptname "no_repl" {*}$arglist]
}
}
} else {
#fallback to external filesystem
set exedir [file dirname $exepath]
set bin_scripts [file join $exedir scriptlib]
set binsibling_scripts [file join [file dirname $exedir] scriptlib]
set script_check_paths [list]
if {[file exists $bin_scripts]} {
lappend script_check_paths $bin_scripts/$scriptname
} else {
switch -- [dict get $resolved scripttype] {
tcl {
set exitinfo [punkshell::do_script $scriptpath {*}$arglist]
}
if {[file exists $binsibling_scripts]} {
lappend script_check_paths $binsibling_scripts/$scriptname
kit {
set exitinfo [punkshell::do_tclkit $scriptpath "no_repl" {*}$arglist]
}
if {[llength $script_check_paths]} {
foreach check_path $script_check_paths {
if {[file exists $check_path]} {
switch -- $lower_ext {
.tcl {
set exitinfo [punkshell::do_script $check_path {*}$arglist]
}
.kit {
set exitinfo [punkshell::do_tclkit $check_path "no_repl" {*}$arglist]
}
}
break
}
}
} else {
puts stderr "script $script_or_kit not found in vfs or in filesystem relative to $exedir"
puts stderr "valid locations:"
if {$kit_base ne ""} {
puts stderr " $kit_base/scriptlib/$scriptname"
}
puts stderr " $bin_scripts/$scriptname"
puts stderr " $binsibling_scripts/$scriptname"
default {
puts stderr "No current support for extension [file extension $scriptpath] via lib: (resolved: $scriptpath)"
}
}
} else {
puts stderr "No current support for extension [file extension $scriptname]"
}
} else {
puts stderr "Path supplied to lib: must be a relative path"
}
}
*.tcl {

141
src/modules/punk/path-999999.0a1.0.tm

@ -504,15 +504,32 @@ namespace eval punk::path {
an executable in <projectroot>/bin.
3. <exedir-grandparent>/scriptlib.
If the name's extension (lowercased) is not one of the known
scriptlib extensions (.tcl .py .pl .ps1 .sh), '.tcl' is
appended - only .tcl scripts may be named extensionlessly.
If the name's extension (lowercased) is one of the known
scriptlib extensions (.tcl .kit .py .pl .ps1 .sh), only that
exact name is searched. Otherwise two spellings are tried within
EACH location before moving to the next (so the kit-internal
precedence above is never crossed by a spelling preference):
1. the exact name as called - accepted only if the file's
first lines identify it as a tcl script: a leading
'# tcl' comment, a shebang naming tclsh/tclkit/wish, or
the portable sh-trampoline ('#!/bin/sh' followed by a
commented 'exec tclsh ...' in the next lines).
2. the name with '.tcl' appended.
An exact-name file that fails the tcl identification check is
skipped (recorded in notes) and resolution falls through.
Returns a dict with keys:
path - resolved absolute path, or empty string if not found
scriptname - the name searched for (extension applied)
scriptname - the relative name that matched; when not found,
the primary name searched for
scripttype - resolved script type: the extension without its
dot (tcl, kit, py, pl, ps1, sh), 'tcl' for an
extensionless file that passed the identification
check, or empty string when not found
candidates - the locations that were (or would be) checked,
for use in error messages.
for use in error messages
notes - explanatory notes (e.g files skipped by the tcl
identification check), for use in error messages.
Raises an error if name is not a relative path."
@leaders
name -type string -optional 0 -help\
@ -522,20 +539,26 @@ namespace eval punk::path {
}
proc scriptlib_resolve {name} {
#shared resolution policy for the 'lib:' script prefix - used by the punk
#executable 'script' subcommand (app-punkscript); app-punkshell's shell
#subcommand encodes the same policy (todo: refactor it onto this proc).
#executable 'script' subcommand (app-punkscript) and the shell subcommand's
#lib: handling (app-punkshell, app_shellrun).
if {[lindex [pathtype $name] 0] ne "relative"} {
error "punk::path::scriptlib_resolve name must be a relative path (got '$name')"
}
set known_extensions [list .tcl .py .pl .ps1 .sh]
set known_extensions [list .tcl .kit .py .pl .ps1 .sh]
#spellings: {relname needs_tcl_check} pairs tried in order within each
#directory - the spelling matching the way the script was called wins
#per-directory, and directory precedence (kit-internal first) is never
#crossed by a spelling preference.
if {[string tolower [file extension $name]] ni $known_extensions} {
#only .tcl scripts allowed to be called extensionlessly
#extensionless (or unknown-extension) call: the exact name wins when
#the file identifies as tcl, else fall through to the .tcl spelling.
set spellings [list [list $name 1] [list $name.tcl 0]]
set scriptname $name.tcl
} else {
set spellings [list [list $name 0]]
set scriptname $name
}
set exepath [file dirname [file normalize [file join [info nameofexecutable] ___]]] ;#symlink resolve
set candidates [list]
#1) kit-internal scriptlib - not overridable externally
set has_zipfs_command [expr {[info commands ::tcl::zipfs::root] ne ""}]
set kit_base ""
@ -550,21 +573,38 @@ namespace eval punk::path {
} elseif {[file exists $exepath/scriptlib]} {
set kit_libdir $exepath/scriptlib
}
set search_dirs [list]
if {$kit_libdir ne ""} {
lappend candidates $kit_libdir/$scriptname
if {[file exists $kit_libdir/$scriptname]} {
return [dict create path $kit_libdir/$scriptname scriptname $scriptname candidates $candidates]
}
lappend search_dirs $kit_libdir
}
#2,3) external scriptlib folders relative to the executable location
set exedir [file dirname $exepath]
foreach dir [list [file join $exedir scriptlib] [file join [file dirname $exedir] scriptlib]] {
lappend candidates [file join $dir $scriptname]
if {[file isdirectory $dir] && [file exists [file join $dir $scriptname]]} {
return [dict create path [file join $dir $scriptname] scriptname $scriptname candidates $candidates]
lappend search_dirs [file join $exedir scriptlib] [file join [file dirname $exedir] scriptlib]
set candidates [list]
set notes [list]
foreach dir $search_dirs {
foreach spelling $spellings {
lassign $spelling relname needs_tcl_check
set fpath [file join $dir $relname]
lappend candidates $fpath
if {![file isfile $fpath]} {
#isfile (not exists) - an extensionless call must not match a directory of the same name
continue
}
if {$needs_tcl_check} {
if {![punk::path::system::scriptfile_is_tcl $fpath]} {
lappend notes "$fpath exists but its first lines do not identify it as tcl (leading '# tcl' comment or tcl shebang) - skipped"
continue
}
set scripttype tcl
} else {
set scripttype [string range [string tolower [file extension $fpath]] 1 end]
}
return [dict create path $fpath scriptname $relname scripttype $scripttype candidates $candidates notes $notes]
}
}
return [dict create path "" scriptname $scriptname candidates $candidates]
return [dict create path "" scriptname $scriptname scripttype "" candidates $candidates notes $notes]
}
proc plain {str} {
@ -1676,14 +1716,71 @@ namespace eval punk::path::system {
#[subsection {Namespace punk::path::system}]
#[para] Internal functions that are not part of the API
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punk::path::system::scriptfile_is_tcl
@cmd -name "punk::path::system::scriptfile_is_tcl"\
-summary\
"Test whether a script file's first lines identify it as a tcl script"\
-help\
"Reads a small bounded chunk (512 bytes) of the file and reports 1 if
the first line is a '# tcl'-prefixed comment (case-insensitive), a
shebang naming tclsh/tclkit/wish, or any other shebang whose next
few lines contain the portable sh-trampoline 'exec tclsh ...' idiom.
A UTF-8 BOM and CRLF line endings are tolerated. Returns 0 for
unreadable files and anything not identified as tcl.
Used by punk::path::scriptlib_resolve to allow extensionless tcl
scripts to be called via the lib: prefix."
@leaders
fpath -type string -optional 0 -help\
"path of the file to examine"
}]
}
proc scriptfile_is_tcl {fpath} {
if {[catch {open $fpath r} fd]} {
return 0
}
set chunk ""
catch {chan configure $fd -encoding utf-8}
catch {set chunk [read $fd 512]}
catch {close $fd}
if {[string index $chunk 0] eq "\uFEFF"} {
#utf-8 BOM as decoded above
set chunk [string range $chunk 1 end]
}
set lines [lmap ln [lrange [split $chunk \n] 0 3] {string trimright $ln \r}]
set line1 [lindex $lines 0]
if {[string index $line1 0] ne "#"} {
return 0
}
if {[regexp {^#!.*\m(tclsh|tclkit|wish)} $line1]} {
#shebang naming a tcl interpreter e.g #!/usr/bin/env tclsh, #!/usr/bin/tclsh9.0
return 1
}
if {[regexp -nocase {^#\s*tcl} $line1]} {
#'# tcl' marker comment (prefix match - '# tclscript' etc also accepted)
return 1
}
if {[string range $line1 0 1] eq "#!"} {
#any other shebang (e.g #!/bin/sh): accept the portable sh-trampoline idiom
# #!/bin/sh
# # \
# exec tclsh "$0" ${1+"$@"}
#(the backslash-continued comment hides the exec line from tcl but not from sh)
foreach ln [lrange $lines 1 end] {
if {[regexp {^\s*exec\s.*\m(tclsh|tclkit|wish)} $ln]} {
return 1
}
}
}
return 0
}
}
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
#documentation-only PUNKARGS registration - discovered lazily if/when punk::args loads
lappend ::punk::args::register::NAMESPACES ::punk::path::argdoc
lappend ::punk::args::register::NAMESPACES ::punk::path::argdoc ::punk::path::system::argdoc
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++

3
src/modules/punk/path-buildversion.txt

@ -1,4 +1,5 @@
0.2.2
0.3.0
#First line must be a semantic version number
#all other lines are ignored.
#0.3.0 - scriptlib_resolve: extensionless tcl scripts resolvable via first-line identification (new system::scriptfile_is_tcl helper - '# tcl' comment, tclsh/tclkit/wish shebang, or sh-trampoline); per-directory spelling order (exact name as called wins over .tcl-appended within each location, kit-internal dir precedence preserved); .kit added to known extensions (previously lib:x.kit wrongly searched x.kit.tcl); return dict keys reworked: scripttype (tcl/kit/py/pl/ps1/sh or empty) + notes added, scriptname now the matched relative name; directories no longer match a spelling (file isfile); now also used by app-punkshell/app_shellrun lib: handling (previously app-punkscript only)
#0.2.2 - added scriptlib_resolve: shared resolution policy for the lib:<script> prefix (kit-internal app/scriptlib first and not externally overridable, then scriptlib dirs relative to the executable); PUNKARGS argdoc namespace + registration added to the module

2
src/tests/shell/AGENTS.md

@ -18,7 +18,7 @@ Tests for shell-level behavior, command-line execution, and stdin/stdout interac
- Tests that spawn the built executable must be hang-proof: use the event-loop `punk_run` pattern (pipe stdin, half-close for EOF, timeout then force-kill) rather than plain `exec`, because known failure modes leave the child waiting on a reopened console.
- `testsuites/punkexe/shellexit.test` guards piped-stdin termination behaviour of the built executable (no `invalid thread handle` on shutdown races, no console-reopen hang on eof/error paths, exitcode propagation); do not weaken these tests - a red result means a regression in punk::repl shutdown or app-punkshell eof handling.
- `testsuites/punkexe/shellnavns.test` proves the namespace-navigation session contract end-to-end over a piped 'src'-mode session (dev modules - deliberate, so current punk::repl/nav source is exercised without a rebuild): n/ sets ns_current, the current namespace continues into `subshell punk` and back out (currently via the documented first-subshell shared-code-interp asymmetry - the SHARED marker pins it; the repl::start seeding template for genuinely separate interps is proven in modules/punk/repl nscurrent.test), the punk::repl 0.5.2 stray-namespace fix holds live, and the session exits cleanly. KNOWN GAP pinned there: piped submissions evaluate at :: despite ns_current (interactive and module-level inscope behaviour differ) - investigate alongside G-038.
- `testsuites/punkexe/scriptexec.test` covers the `script` subcommand (app-punkscript, G-015): piped execution + honest exit codes, result echo, file-form argv, the `lib:<name>` scriptlib-resolution mechanism, and the tclsh-matching Tk main-loop behaviour. The `lib:` and Tk cases resolve committed fixtures under `scriptlib/_punktest/` (test-owned; a `lib:`-resolvable fixture cannot live in a tcltest temp dir since resolution only searches scriptlib locations relative to the exe). Tk cases are gated by the `punktk` constraint, auto-detected by probing whether the kit can load Tk.
- `testsuites/punkexe/scriptexec.test` covers the `script` subcommand (app-punkscript, G-015): piped execution + honest exit codes, result echo, file-form argv, the `lib:<name>` scriptlib-resolution mechanism (incl. punk::path 0.3.0 extensionless-tcl matching: first-line identification, called-spelling precedence over a `.tcl` sibling, sh-trampoline shebang acceptance, and the non-tcl skip note on not-found), and the tclsh-matching Tk main-loop behaviour. The `lib:` and Tk cases resolve committed fixtures under `scriptlib/_punktest/` (test-owned; a `lib:`-resolvable fixture cannot live in a tcltest temp dir since resolution only searches scriptlib locations relative to the exe). Tk cases are gated by the `punktk` constraint, auto-detected by probing whether the kit can load Tk.
- punkexe tests run against the built binary: after changing punk::repl or app-punkshell source, rebuild via `make.tcl packages`, `make.tcl vfscommonupdate -confirm 0` (unattended runs must pass the flag - the REPLACE confirmation aborts fast on non-interactive stdin instead of reading a piped `y`), then `make.tcl project` before trusting results; `make.tcl project` alone does not refresh `_vfscommon.vfs`.
- `testsuites/binscripts/runtimebash_wsl.test` (G-059) exercises the runtime.cmd unix payload (`src/scriptapps/bin/runtime.bash`) through WSL: active/use/run resolution, env override, stale-active guidance, single-candidate fallback, and `list -remote` local-vs-server comparison (offline via a crafted cached sha1sums.txt + `PUNKBIN_URL` pointed at an unreachable address). Gated by the `wsllinux` capability constraint from `src/tests/testsupport/wslprobe.tcl`; all execution happens in a WSL-native staging dir, and a final test asserts the Windows checkout's git status is unchanged by the run.
- `testsuites/punkexe/staticruntime.test` (G-058) targets a kit built on a runtime with statically-linked packages: resolved from `env(PUNK_STATICKIT_TEST_EXE)`, else `<projectroot>/bin/punk91.exe`, gated by the capability-probed `statickitavailable` constraint (a probe run must show a captured `::punkboot::static_packages` baseline including Thread - not mere file existence). It asserts repl boot without the codethread Thread failure, Thread resolution in the code interp / fabricated interps / snapshot-seeded threads, and twapi resolution per the static-vs-bundled policy. The verification runtime (tclsfe-x64.exe) is pinned in the punkbin artifact repo so the constraint is satisfiable off the original dev machine. Kit script-exec paths must be passed with forward slashes.

59
src/tests/shell/testsuites/punkexe/scriptexec.test

@ -2,9 +2,10 @@ package require tcltest
#Tests for the built punk executable's 'script' subcommand (goal G-015, app-punkscript).
#Covers: piped-stdin execution and honest exit codes, result echo, file-form argv passing,
#the lib:<name> scriptlib-resolution mechanism (incl. subpath and not-found), and the
#tclsh-matching Tk main-loop behaviour (a GUI script's event loop is serviced after the
#script body - punkshell 0.4.1).
#the lib:<name> scriptlib-resolution mechanism (incl. subpath, not-found, and punk::path
#0.3.0 extensionless-tcl matching: first-line identification, called-spelling precedence,
#sh-trampoline shebang, non-tcl skip note), and the tclsh-matching Tk main-loop behaviour
#(a GUI script's event loop is serviced after the script body - punkshell 0.4.1).
#
#The lib: and Tk cases resolve committed fixtures under scriptlib/_punktest/ (test-owned;
#see that folder's README). lib: resolution searches scriptlib locations relative to the
@ -274,6 +275,58 @@ namespace eval ::testspace {
lappend result [dict get $rd exitcode]
} -result [list 0 1 1 1]
# -- lib: extensionless tcl matching (punk::path 0.3.0: first-line identification) --------------
test script_lib_extensionless_file {'lib:_punktest/extless' resolves an extensionless '# tcl'-marked file}\
-constraints punkexeavailable -setup $common -body {
variable punkexe
set rd [punk_run $punkexe [list script lib:_punktest/extless] ""]
lappend result [dict get $rd timedout]
lappend result [expr {[string first "PUNKTEST_EXTLESS" [dict get $rd output]] >= 0}]
lappend result [dict get $rd exitcode]
} -result [list 0 1 0]
test script_lib_dual_precedence_bare {'lib:_punktest/dual' selects the extensionless spelling when both exist}\
-constraints punkexeavailable -setup $common -body {
variable punkexe
set rd [punk_run $punkexe [list script lib:_punktest/dual] ""]
lappend result [dict get $rd timedout]
lappend result [expr {[string first "PUNKTEST_DUAL=bare" [dict get $rd output]] >= 0}]
lappend result [expr {[string first "PUNKTEST_DUAL=dottcl" [dict get $rd output]] >= 0}]
lappend result [dict get $rd exitcode]
} -result [list 0 1 0 0]
test script_lib_dual_precedence_dottcl {'lib:_punktest/dual.tcl' selects the .tcl spelling when both exist}\
-constraints punkexeavailable -setup $common -body {
variable punkexe
set rd [punk_run $punkexe [list script lib:_punktest/dual.tcl] ""]
lappend result [dict get $rd timedout]
lappend result [expr {[string first "PUNKTEST_DUAL=dottcl" [dict get $rd output]] >= 0}]
lappend result [expr {[string first "PUNKTEST_DUAL=bare" [dict get $rd output]] >= 0}]
lappend result [dict get $rd exitcode]
} -result [list 0 1 0 0]
test script_lib_shebang_trampoline {'lib:_punktest/shebang_sh' - sh-trampoline shebang identifies as tcl, argv passes}\
-constraints punkexeavailable -setup $common -body {
variable punkexe
set rd [punk_run $punkexe [list script lib:_punktest/shebang_sh alpha beta] ""]
lappend result [dict get $rd timedout]
lappend result [expr {[string first "PUNKTEST_SHEBANG_SH" [dict get $rd output]] >= 0}]
lappend result [expr {[string first "argv=alpha beta" [dict get $rd output]] >= 0}]
lappend result [dict get $rd exitcode]
} -result [list 0 1 1 0]
test script_lib_notatcl_skipped {'lib:_punktest/notatcl' (non-tcl first line, no .tcl sibling) fails with a skip note}\
-constraints punkexeavailable -setup $common -body {
variable punkexe
set rd [punk_run $punkexe [list script lib:_punktest/notatcl] ""]
lappend result [dict get $rd timedout]
lappend result [expr {[string first "not found" [dict get $rd output]] >= 0}]
lappend result [expr {[string first "do not identify it as tcl" [dict get $rd output]] >= 0}]
lappend result [expr {[string first "PUNKTEST_NOTATCL" [dict get $rd output]] >= 0}]
lappend result [dict get $rd exitcode]
} -result [list 0 1 1 0 1]
# -- Tk main-loop behaviour (constraint punktk) ------------------------------------------------
test script_tk_countdown_serviced {GUI script's event loop is serviced: after-callback runs and exits 0}\

Loading…
Cancel
Save