Browse Source

G-093 rework: punk::path *** pathglob + separable lattice; runtests passthrough targeting

User-approved contract amendment replacing the day-1 file-relpath filter: one
directory-oriented pathglob semantics at every layer, with the ergonomics gap
closed in the syntax itself.

punk::path 0.4.0:
- *** as a whole segment matches ZERO or more segments (join-aware regex in
  pathglob_as_re: an adjacent separator is absorbed into an optional group),
  so X/*** = X and everything below - the subtree spelling; ***/f.txt matches
  at any depth including the root.
- include separability fix: an exact or single-segment-glob full match no
  longer sets allbelow (the deep walk previously dragged the whole subtree in
  for X and X/* patterns; the zipfs walk was already separable).
- exclude separability fix (new exclude_state classifier, both walks): only
  **|***-tail patterns prune the matched subtree; other matches exclude just
  that directory's files - the **/_aside vs **/_aside/** argdoc distinction is
  now real. Dead _path_matches_any removed.
- legacy bare-* include collapse removed (a * in -include-paths is the
  one-below lattice form, not a match-everything alias; no caller used it).
- globmatchpath default-nocase driveletter exception rewritten as a direct
  regex-head rewrite (the regex-text file-split broke on c:/***'s glued group
  and accepted non-alpha drives).
- path.test +5 lattice/*** pins (40 total).

runtests.tcl:
- -include-paths/-exclude-paths pass through to treefilenames unchanged;
  argdocs teach the separable X | X/* | X/** | X/*** lattice; directory and
  file-name axes independent (tails); single-file targeting = exact dir
  pattern + file tail.
- -serial-paths reconciled to the same directory-of-file reading (default now
  modules/punk/console/*** modules/opunk/console/*** via
  runtests_discovery::dir_matches_any) - the pre-existing file-path serial
  matching was the other same-option-different-referent divergence.
- per-pattern zero-match stderr advisory (the X/**-for-subtree foot-gun points
  at X/***).
- json report emitter: whitespace-padded entier-looking values now take the
  quoted-escaped path ('string is entier -strict' accepts surrounding
  whitespace - a failed test's "0\n" result_was embedded a raw newline and
  truncated the parseable report line).
- testsupport/discovery.tcl now a thin passthrough (requires punk::path
  0.4.0-); pathdiscovery.test rewritten to the directory-semantics contract
  (22 tests incl. the X/** zero-match advisory characterization); AGENTS.md
  targeting guidance swept to /*** across src/tests and src/modules/opunk.

Verification (native tclsh 9.0.3, piped, serialized): 45-probe matrix, path
suite 40/40, runner suite 22/22; vs the pre-G-093 baseline the default
discovery FILE SET differs by exactly the added runner suite and result
parity only by the five new path tests (+ that suite); mode parity -jobs 16
vs singleproc: PARITY ok (94/1037/failed=1 exec-14.3). Goal contract amended
in the detail file; full record there.

Claude-Session: https://claude.ai/code/session_01BNUVVkYq9vHa6G3S9a3XTZ
Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com
master
Julian Noble 2 weeks ago
parent
commit
bc958300b1
  1. 157
      goals/G-093-runtests-includepaths-discovery.md
  2. 2
      src/modules/opunk/AGENTS.md
  3. 195
      src/modules/punk/path-999999.0a1.0.tm
  4. 3
      src/modules/punk/path-buildversion.txt
  5. 16
      src/tests/AGENTS.md
  6. 4
      src/tests/core/AGENTS.md
  7. 4
      src/tests/core/tcl/testsuites/tests/AGENTS.md
  8. 6
      src/tests/modules/AGENTS.md
  9. 82
      src/tests/modules/punk/path/testsuites/tests/path.test
  10. 4
      src/tests/runner/AGENTS.md
  11. 203
      src/tests/runner/testsuites/discovery/pathdiscovery.test
  12. 108
      src/tests/runtests.tcl
  13. 4
      src/tests/shell/AGENTS.md
  14. 106
      src/tests/testsupport/discovery.tcl

157
goals/G-093-runtests-includepaths-discovery.md

@ -2,8 +2,8 @@
Status: active
Scope: src/tests/runtests.tcl, src/modules/punk/path-999999.0a1.0.tm (treefilenames/globmatchpath as implicated), src/modules/punk/args-999999.0a1.0.tm (only if -multiple implicated), src/tests/ (runner-targeting regression suite), src/tests/AGENTS.md
Goal: runtests.tcl -include-paths discovery honours its documented contract - a single value carrying multiple space-separated glob patterns includes every pattern's matches, repeated -include-paths flags accumulate, and deep ** patterns match regardless of whether explicit file-tail globs are supplied - and a new -exclude-paths option (treefilenames' existing capability exposed, or an equivalent post-discovery globmatchpath filter over the relative paths) gives broad development runs a middle ground between full and targeted by excluding glob-matched files, with both directions' semantics pinned by the same regression suite and the include-side root cause recorded.
Acceptance: the two 2026-07-19 failures reproduce as characterization tests before the fix and pass after it - (a) a single -include-paths value listing four deep patterns (the G-092 measurement set) discovers files from all four subtrees under the default *.test tail glob, and (b) two repeated -include-paths flags naming distinct subtrees discover both (observed matching zero files); the root cause is identified and recorded in this file; -exclude-paths is exposed on runtests (argdoc + src/tests/AGENTS.md documented, accumulate semantics matching -include-paths, exclusion winning over inclusion for the same file) and a full run excluding a named subtree discovers exactly the default-discovery file list minus that subtree; a regression suite under src/tests covers both options across single-value multi-pattern, repeated-flag, deep-pattern and tail-glob-present/absent combinations plus include/exclude interaction; a full-suite default-discovery run before and after the changes compares PARITY ok via scriptlib/developer/runtests_parity.tcl (default '**' discovery with no exclusions must be unaffected); src/tests/AGENTS.md path-targeting wording is reconciled with the verified semantics.
Goal: runtests.tcl path targeting shares one directory-oriented pathglob contract with punk::path::treefilenames - patterns match the directory containing a test file, a single -include-paths value carrying multiple space-separated patterns includes every pattern's matches, repeated flags accumulate, the pathglob syntax gains *** (zero or more segments) so X/*** expresses a base-inclusive subtree, and a new -exclude-paths option (same syntax and accumulation, exclusion winning) gives broad development runs a middle ground between full and targeted - with the include and exclude lattices (X, X/*, X/**, X/***) separable, pinned by regression suites at both the punk::path and runner levels, and the root cause of the 2026-07-19 failures recorded in this file.
Acceptance: the G-092 four-subtree measurement set spelled X/*** discovers files from all four subtrees under the default *.test tail glob, and the X/** spelling of a base-direct suite matches zero files while emitting a stderr advisory (both pinned as characterization tests); two repeated -include-paths flags naming distinct subtrees in X/*** form discover both and equal the single-value form; the root cause of the original failures is identified and recorded in this file; punk::path 0.4.0 implements *** across pathglob_as_re/globmatchpath/treefilenames with the include and exclude lattice forms separable and pinned in the punk::path suite; -exclude-paths is exposed on runtests (argdoc + src/tests/AGENTS.md documented, accumulate semantics matching -include-paths, exclusion winning over inclusion for the same file) and a full run excluding a named subtree discovers exactly the default-discovery file list minus that subtree; -serial-paths classifies by the same directory-of-file reading; a regression suite under src/tests covers both options across single-value multi-pattern, repeated-flag, lattice-form and tail-glob-present/absent combinations plus include/exclude interaction; a full-suite default-discovery run before and after the changes compares PARITY ok via scriptlib/developer/runtests_parity.tcl (default '**' discovery with no exclusions must be unaffected); src/tests/AGENTS.md path-targeting wording is reconciled with the verified semantics.
## Context
@ -66,24 +66,37 @@ not multi-pattern interaction and not punk::args:
- The with-tails invocation that "worked" during G-092 worked because its subtrees kept
files deeper than the pattern roots - tail globs were never a factor in the failure.
Fix shape: runtests' contract (matching each test file's RELATIVE PATH, consistent with
its pre-existing -serial-paths matching at the same layer) is implemented as a post-walk
filter in src/tests/testsupport/discovery.tcl - treefilenames walks the whole tree
(match-everything include, tailglobs as before) and runtests_discovery::filter_paths
applies include/exclude patterns to the tailbase-relative file paths via globmatchpath
(include on any match, exclusion wins). punk::path is deliberately untouched.
Fix shape (final, 2026-07-20): the ergonomics gap is closed at the pathglob-syntax
layer - punk::path 0.4.0 adds *** (zero or more segments as a whole segment), so X/***
is the base-inclusive subtree spelling and runtests passes its patterns through to
treefilenames unchanged: one directory-oriented semantics at every layer, with the
file-name axis staying on the trailing tail globs. Two engine separability defects
found en route are fixed in the same punk::path release: an exact or
single-segment-glob include match no longer drags the whole subtree in (the deep walk
previously set allbelow on ANY full match; the zipfs walk was already separable), and
non-spanning exclude matches now drop only the matched directory's files instead of
pruning (making the argdoc's **/_aside vs **/_aside/** distinction real). The
2026-07-19 interim fix (file-relpath post-filtering at the runner layer) was reworked
away at user direction - see Alternatives considered.
## Alternatives considered
- Passing translated patterns down to treefilenames (X/** expanded to {X X/**} to pull in
boundary-direct files) - rejected: cannot express exact-file patterns or per-pattern
tail constraints, and leaves runtests coupled to directory-oriented semantics.
- Changing treefilenames' own X/** semantics to include boundary-direct files - rejected:
its documented directory-oriented contract is self-consistent and other callers depend
on the module; a file-path matching mode for punk::path remains a possible future
enhancement (out of scope here).
- Walk pruning is lost for targeted runs (the walk is now always full-tree) - accepted:
the default '**' run already walked the whole tree, and src/tests is small.
- File-relpath post-filtering at the runner layer (SHIPPED 2026-07-19, reworked away
2026-07-20 at user direction): treefilenames walked everything and
runtests_discovery::filter_paths matched include/exclude patterns against the
tailbase-relative FILE paths. It fixed the boundary miss and made exact file paths
valid patterns, but gave -include-paths a different meaning at the runtests layer
than in treefilenames (same name, same syntax, different referent), made the trailing
tail-glob axis partially redundant (a pattern's final segment also constrained the
filename), and lost walk pruning. The user's review framed the root problem as syntax
ergonomics (no zero-or-more-segments form) rather than the matching target - hence
the *** rework. Single-file targeting is now a directory pattern plus a file tail.
- Passing translated patterns down to treefilenames (X/** expanded to {X X/**} to pull
in boundary-direct files) - rejected: two spellings for one intent, and unable to
express per-pattern filename constraints.
- Changing treefilenames' X/** semantics to include boundary-direct files - rejected:
the in-dir / one-below / one-or-more-below separability is a deliberate design; ***
completes the lattice instead of collapsing it.
## Notes
@ -96,23 +109,38 @@ applies include/exclude patterns to the tailbase-relative file paths via globmat
motivation examples: excluding shell/** when no built kit is present, runtimebash_wsl
without WSL, or a heavy suite family unrelated to the change - most valuable for
sequential/tcl8.6 contexts where full runs still cost minutes (-jobs 8 full runs are
~1m26s post G-092). RESOLVED: the include-side root cause made the post-discovery
globmatchpath filter the home for both directions (see Root cause above).
- Behaviour change pinned deliberately: a bare directory path without a glob tail
(e.g -include-paths modules/punk/args) now matches NOTHING (append /** for a subtree).
Under the old directory-oriented semantics it matched files directly in that directory.
In exchange, an exact relative FILE path is now a valid single-file targeting pattern.
Neither form was documented at the runtests level before; all documented usage is X/**.
~1m26s post G-092). RESOLVED (final form 2026-07-20): a thin passthrough to
treefilenames' own -exclude-paths, which gained the exclude-side separability fix
(**|***-tail prunes, other matches drop only that directory's files).
- Final semantics notes: a bare directory path X includes exactly the files directly in
X (the lattice in-dir form); X/** deliberately excludes files directly in X, and a
received include pattern contributing no files earns a stderr advisory pointing at
X/***; single-file targeting is an exact directory pattern plus a file tail
(-include-paths modules/punk/args/testsuites/args dynamic.test). The interim
2026-07-19 file-relpath semantics (exact FILE paths as patterns; bare dir matching
nothing) lasted one day and are superseded - see Alternatives considered.
- punk::path 0.4.0 ride-alongs (same release, see path-buildversion.txt): the legacy
bare-* include collapse is removed (a * in treefilenames -include-paths no longer
means match-everything - it is the one-below lattice form; no repo caller used it),
and the globmatchpath default-nocase driveletter exception is now a direct regex-head
rewrite (the old file-split of the regex text broke on c:/***'s glued group and
accepted non-alpha drives). subfolders/subfolders1 rule-compiler machinery untouched
(its own inadequacy TODO stands in the module).
- -serial-paths reconciled to the same directory-of-file reading (default switched to
modules/punk/console/*** + modules/opunk/console/*** - same file set as the old
file-path-oriented console/** default): the pre-existing serial-vs-discovery
divergence was the codebase's other same-option-different-referent case; both gone.
- runtests.tcl -discover-only 1 (added with the fix): prints the discovered list and
exits before any run machinery - the observation channel used by the regression suite
and for cheap human/agent targeting checks. Emits a well-formed tcl-list line
'RUNTESTS_FILES <path>...' (marker first element, emitted last).
- Two-stage boot (added for -discover-only usability): discovery-phase loads only
punk::args + punk::path (via testsupport/discovery.tcl); punk/Thread/shellrun/
punk::tcltestrun and the testinterp/child path assembly load after the -discover-only
exit. Discover-only child processes dropped ~11.2s -> ~0.5s, and the regression suite's
8 end-to-end children dropped its file wall 93s -> ~7s (parallel-safe, well under the
post-G-092 ~31s max-child bound).
punk::args + punk::path (via testsupport/discovery.tcl, which requires punk::path
0.4.0- - the runner parent resolves it from the refreshed bootsupport snapshot);
punk/Thread/shellrun/punk::tcltestrun and the testinterp/child path assembly load
after the -discover-only exit. Discover-only child processes dropped ~11.2s -> ~0.5s,
and the regression suite's end-to-end children dropped its file wall 93s -> ~7s
(parallel-safe, well under the post-G-092 ~31s max-child bound).
- Verification context note: run verification through a context whose children have no
attached Windows console (e.g the agent Bash tool; Git Bash/mintty). Under the agent
PowerShell tool the children inherit an attached console with redirected stdio and the
@ -170,5 +198,72 @@ on the console-context requirement):
under the stage-1 boot (textblock soft-require resolves from bootsupport, ~0.9s);
goals_lint clean; no runtests_tmp leak from -discover-only.
Remaining for acceptance: none known - all acceptance clauses verified above; the
achieved flip is the user's call.
Status at the time: all original acceptance clauses verified. Superseded the next day by
the user-approved contract amendment and *** rework below.
### 2026-07-20 rework to *** directory-oriented pass-through (user-approved contract amendment)
User review of the 2026-07-19 increment identified the divergence cost (-include-paths
meaning different things at the runner than in treefilenames) and proposed the ***
syntax; the Goal/Acceptance contract was amended to the *** spelling with user approval
and the increment reworked the same day.
Landed:
- punk::path 0.4.0 (buildversion + changelog; root modules, bootsupport and
_vfscommon.vfs regenerated via make.tcl modules + bootsupport + vfscommonupdate):
*** zero-or-more-segments in pathglob_as_re (join-aware regex construction - a
trailing/medial/leading *** absorbs one adjacent separator inside an optional group)
and globmatchpath; prefix-viability treats *** like **; include separability fix
(an exact or single-segment-glob full match no longer sets allbelow); exclude
separability fix via the new exclude_state classifier (subtree prune only for **|***
tails, both walks; dead _path_matches_any removed); legacy bare-* include collapse
removed; driveletter default-nocase exception now a direct regex-head rewrite.
- path.test +5 (40 total): *** matrix, *** drive-letter case, include lattice
separability, *** subsumes the {X/** X} pattern pair, exclude lattice separability.
- runtests.tcl: -include-paths/-exclude-paths pass through to treefilenames (one
directory-oriented semantics everywhere); argdocs rewritten to the lattice;
-serial-paths default modules/punk/console/*** + modules/opunk/console/*** with
directory-of-file classification (runtests_discovery::dir_matches_any - the
pre-existing file-path serial matching was the codebase's other
same-option-different-referent case); per-pattern zero-match stderr advisory
pointing X/** misses at X/***; json emitter guard for whitespace-padded
entier-looking values (a failed test's "0\n" result_was embedded a raw newline in
the json report - string is entier -strict accepts surrounding whitespace).
- testsupport/discovery.tcl: thin treefilenames passthrough + dir_matches_any
(requires punk::path 0.4.0-).
- pathdiscovery.test rewritten to the directory-semantics contract (22 tests):
classifier lattice, fixture-walk lattice including files-level vs subtree exclusion,
e2e X/*** four-subtree set, repeated-flag equivalence, exclude-minus-subtree,
dir+tail single-file targeting, and the X/** zero-match + advisory characterization.
- AGENTS.md sweep: src/tests contract bullets, Work Guidance and the -jobs serial
default mention; runner/AGENTS.md; and the /** -> /*** targeting examples in
src/tests/modules, src/tests/shell, src/tests/core, src/tests/core/tcl/testsuites/
tests and src/modules/opunk AGENTS.md files.
Verification (2026-07-20, native tclsh 9.0.3, Bash context, runs serialized with no
concurrent checkout edits - an earlier battery invalidated itself when
runtimebash_wsl_leaves_checkout_untouched failed under mid-run goal-file edits, which
is also what surfaced the json emitter bug):
- Probes: 45-case globmatchpath/treefilenames matrix (both lattice directions,
trailing/medial/leading ***, drive case) all pass; path suite 40/40; runner suite
22/22 (both driven through runtests with -include-paths <x>/*** targeting).
- Acceptance (a): the four-subtree G-092 set in X/*** spelling discovers 13 files
across all four subtrees (pathdiscovery-3.0); the X/** spelling on the base-direct
scriptwrap suite matches zero files and emits the stderr advisory
(pathdiscovery-3.6).
- Acceptance (b): repeated X/*** flags accumulate and equal the single-value form
(pathdiscovery-3.1).
- Exclude: a full run with -exclude-paths X/*** equals the default list minus that
subtree, order preserved (pathdiscovery-3.3).
- Parity battery vs the pre-G-093 baseline (default -jobs 16: 93 files / 1010 tests /
failed=1 exec-14.3 only): clean exclude-runner run differs ONLY by path.test's five
new passing tests; clean default run differs ONLY by that plus the added runner
suite file; the baseline-vs-current default DISCOVERY SET differs by exactly the one
added suite file - default '**' discovery is unaffected. Mode parity post-rework:
-jobs 16 vs singleproc sequential PARITY ok (94 files / 1037 tests / failed=1).
- discover-only child ~0.5s under the two-stage boot; -help renders the new argdocs.
Remaining for acceptance: none known - all amended acceptance clauses verified above;
the achieved flip is the user's call.

2
src/modules/opunk/AGENTS.md

@ -33,7 +33,7 @@ Modules under the `opunk::*` namespace explore value-based class implementations
## Verification
- `tclsh src/tests/runtests.tcl -report compact -show-passes 0 -include-paths modules/opunk/<module>/**` passes for the touched module.
- `tclsh src/tests/runtests.tcl -report compact -show-passes 0 -include-paths modules/opunk/<module>/***` passes for the touched module.
- `tclsh src/make.tcl modules` completes without errors after module additions.
## Child DOX Index

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

@ -725,9 +725,10 @@ namespace eval punk::path {
proc pathglob_as_re {pathglob} {
#*** !doctools
#[call [fun pathglob_as_re] [arg pathglob]]
#[para] Returns a regular expression for matching a path to a glob pattern which can contain glob chars *|? in any segment of the path structure
#[para] Returns a regular expression for matching a path to a glob pattern which can contain glob chars *|**|***|? in any segment of the path structure
#[para] Does not support square bracket globs or character classes.
#[para] ** matches any number of subdirectories.
#[para] ** as a whole segment matches one or more segments.
#[para] *** as a whole segment matches zero or more segments - e.g /etc/*** will match /etc itself as well as anything below it. (added 2026-07-20, G-093)
#[para] e.g /etc/**/*.txt will match any .txt files at any depth below /etc (except directly within /etc itself)
#[para] e.g /etc/**.txt will match any .txt files at any depth below /etc
#[para] any segment that does not contain ** must match exactly one segment in the path
@ -738,6 +739,7 @@ namespace eval punk::path {
#todo - consider whether a way to escape the glob chars ? * is practical - to allow literals ? *
# - would require counting immediately-preceding backslashes
set zom "\x00zom\x00" ;#zero-or-more-segments marker for whole-segment *** (segment regexes never contain \x00)
set pats [list]
foreach seg [file split $pathglob] {
if {[string range $seg end end] eq "/"} {
@ -746,6 +748,12 @@ namespace eval punk::path {
switch -- $seg {
* {lappend pats {[^/]*}}
** {lappend pats {.*}}
*** {
#zero or more whole segments - adjacent *** duplicates collapse to one
if {[lindex $pats end] ne $zom} {
lappend pats $zom
}
}
default {
set seg [string map [list ^ {\^} $ {\$} \[ {\[} \] {\]} ( {\(} ) {\)} \{ \\\{ \\ {\\}] $seg] ;#treat regex characters (or tcl glob square bracket chars) in the input as literals
#set seg [string map [list . {[.]}] $seg]
@ -759,7 +767,34 @@ namespace eval punk::path {
}
}
}
return "^[join $pats /]\$"
#join with / - except around zero-or-more markers, which must absorb one adjacent
#separator (a plain join cannot express 'zero segments'):
# X/*** -> ^X(?:/.*)?$ (trailing marker absorbs the preceding separator)
# ***/X a/***/b -> (?:.*/)?X ... (marker carries its trailing separator inside the
# optional group; no separator follows the marker)
# *** -> ^.*$
set n [llength $pats]
set out ""
for {set i 0} {$i < $n} {incr i} {
set pat [lindex $pats $i]
set is_zom [expr {$pat eq $zom}]
set is_last [expr {$i == $n - 1}]
if {$i > 0 && [lindex $pats $i-1] ne $zom && !($is_zom && $is_last)} {
append out /
}
if {$is_zom} {
if {$n == 1} {
append out {.*}
} elseif {$is_last} {
append out {(?:/.*)?}
} else {
append out {(?:.*/)?}
}
} else {
append out $pat
}
}
return "^${out}\$"
}
punk::args::define {
@ -775,10 +810,12 @@ namespace eval punk::path {
with 1 or more segments in between (so it will not match /usr/bin).
A pattern such as /usr/** will match any path that has /usr as the first segment, with 1 or more segments
following (so it will not match /usr itself).
A pattern such as /usr/*** will match /usr itself as well as any path below /usr
- *** as a whole segment matches zero or more segments (added 2026-07-20, G-093).
A pattern such as **/*.txt will match any path that ends with .txt, with 1 or more leading segments
(so it will not match test.txt or .txt).
(so it will not match test.txt or .txt). Use ***/*.txt to also match a bare test.txt.
A pattern such as ** will match any path.
The glob characters * and ? are the only special characters in the pathglob syntax.
The glob characters * and ? (and the whole-segment forms ** and ***) are the only special characters in the pathglob syntax.
- they are treated as glob characters regardless of where they appear in the pathglob string.
Note that this is different from other Tcl glob contexts where square brackets can be used.
The pathglob syntax treats other characters, including square brackets as literals.
@ -849,12 +886,12 @@ namespace eval punk::path {
set ismatch [regexp $re $path] ;#explicit -nocase 0 - require exact match of path literals including driveletter
} else {
#caller is using default for -nocase - which indicates case sensitivity - but we have an exception for the driveletter.
set re_segments [file split $re] ;#Note that file split c:/etc gives {c:/ etc} but file split ^c:/etc gives {^c: etc}
set first_seg [lindex $re_segments 0]
if {[regexp {^\^(.{1}):$} $first_seg _match driveletter]} {
#first part of re is like "^c:" i.e a drive letter
set chars [string tolower $driveletter][string toupper $driveletter]
set re [join [concat "^\[$chars\]:" [lrange $re_segments 1 end]] /] ;#rebuild re with case insensitive driveletter only - use join - not file join. file join will misinterpret leading re segment.
#Direct head rewrite of the anchored regex: if the pattern begins with a windows
#drive segment (^c:...) make just the drive letter class-insensitive.
#(The previous file-split of the regex text broke on forms where a group is glued
# to the drive - e.g ^c:(?:/.*)?$ from c:/*** - and allowed non-alpha 'drives'.)
if {[regexp {^\^([A-Za-z]):(.*)$} $re _match driveletter rest]} {
set re "^\[[string tolower $driveletter][string toupper $driveletter]\]:$rest"
}
#puts stderr "-->re: $re"
set ismatch [regexp $re $path]
@ -1142,7 +1179,10 @@ namespace eval punk::path {
set pattern_head [lindex $pattern_parts 0]
set path_head [lindex $path_parts 0]
if {$pattern_head eq "**"} {
if {$pattern_head eq "**" || $pattern_head eq "***"} {
#both spanning forms admit consuming zero path segments here (viability only
#asks whether a match below remains possible - *** additionally full-matches
#with zero segments, which globmatchpath handles)
if {[_pattern_prefix_viable_parts [lrange $pattern_parts 1 end] $path_parts]} {
return 1
}
@ -1160,6 +1200,10 @@ namespace eval punk::path {
}
proc pattern_boundary {pattern} {
#the directory at which a trailing-** pattern's subtree begins (that directory
#itself does not full-match X/** - the boundary branch grants descent+allbelow
#without including its files). Trailing-*** patterns need no boundary: they
#full-match their base directory directly.
set parts [file split $pattern]
if {[llength $parts] >= 2 && [lindex $parts end] eq "**"} {
return [file join {*}[lrange $parts 0 end-1]]
@ -1167,6 +1211,37 @@ namespace eval punk::path {
return ""
}
proc pattern_tail_spans {pattern} {
#1 when the pattern's final segment is ** or *** - a directory matching such a
#pattern implies every deeper directory also matches it (basis for the include
#allbelow shortcut and the exclude subtree prune). An exact or single-segment-glob
#tail does not extend below its match, keeping the in-dir (X), one-below (X/*),
#one-or-more-below (X/**) and zero-or-more-below (X/***) forms separable.
set last [lindex [file split $pattern] end]
return [expr {$last eq "**" || $last eq "***"}]
}
proc exclude_state {exclude_paths path} {
#Directory-oriented exclusion classification (separability fix 2026-07-20, G-093 -
#previously ANY matching pattern pruned the whole subtree, so an exact exclude
#like **/_aside also killed everything below _aside, contradicting the argdoc's
#distinct **/_aside vs **/_aside/** forms and the punk::path::subfolders precedent):
# subtree - a matching pattern's final segment is **|*** : prune path and all below
# files - matched by non-spanning pattern(s) only: exclude the files directly in
# path, but keep walking below it
# none - not excluded
set state none
foreach xp $exclude_paths {
if {[::punk::path::globmatchpath $xp $path]} {
if {[pattern_tail_spans $xp]} {
return subtree
}
set state files
}
}
return $state
}
proc directory_state {glob_paths path inherited_allbelow} {
if {$inherited_allbelow} {
return [dict create include_files 1 recurse_below 1 next_allbelow 1]
@ -1180,8 +1255,17 @@ namespace eval punk::path {
if {[::punk::path::globmatchpath $gp $path]} {
set include_files 1
set recurse_below 1
set next_allbelow 1
break
if {[pattern_tail_spans $gp]} {
#trailing **|*** - every deeper directory also matches this pattern
set next_allbelow 1
break
}
#exact or single-segment-glob tail: this match includes only the files
#directly here - later patterns may still grant descent/allbelow
#(previously any full match set next_allbelow and broke, dragging the
# whole subtree in for exact/X-star patterns - separability fix 2026-07-20,
# G-093; the zipfs walk already behaved separably)
continue
}
set boundary [pattern_boundary $gp]
@ -1233,15 +1317,6 @@ namespace eval punk::path {
}
}
proc _path_matches_any {patterns path} {
foreach pattern $patterns {
if {[::punk::path::globmatchpath $pattern $path]} {
return 1
}
}
return 0
}
proc _tailbase_relative {tailbase path} {
if {$tailbase eq ""} {
return $path
@ -1297,10 +1372,10 @@ namespace eval punk::path {
set directory [pwd]
}
#-include-paths patterns are taken as-is: a bare * is the one-below lattice form,
#not a legacy match-everything alias (special-case collapse removed 2026-07-20,
#G-093 - use ** or *** for match-everything)
set glob_paths [dict get $opts -include-paths]
if {"*" in $glob_paths} {
set glob_paths {*}
}
set sortmode [dict get $opts -sort]
if {$sortmode eq "natural"} {
@ -1341,13 +1416,14 @@ namespace eval punk::path {
return [walk_treefilenames_zipfs $state]
}
set opt_dir_match [_tailbase_match_path $opt_tailbase $opt_dir]
if {[_path_matches_any $opt_exclude_paths $opt_dir_match]} {
set dir_exclude_state [exclude_state $opt_exclude_paths $opt_dir_match]
if {$dir_exclude_state eq "subtree"} {
return [list]
}
set files [list]
set dir_state [directory_state $opt_glob_paths $opt_dir_match $callallbelow]
if {[dict get $dir_state include_files]} {
if {[dict get $dir_state include_files] && $dir_exclude_state eq "none"} {
if {[catch {glob -nocomplain -dir $opt_dir -type f -- {*}$tailglobs} matches]} {
puts stderr "treefilenames error while listing files in dir $opt_dir\n $matches"
set dirfiles [list]
@ -1389,19 +1465,17 @@ namespace eval punk::path {
}
set okdirs [list]
foreach dir $dirdirs {
if {![_path_matches_any $opt_exclude_paths [_tailbase_match_path $opt_tailbase $dir]]} {
#only subtree exclusions prune descent here - a child matched by a
#non-spanning exclude still walks (its own entry pass drops its files)
if {[exclude_state $opt_exclude_paths [_tailbase_match_path $opt_tailbase $dir]] ne "subtree"} {
lappend okdirs $dir
}
}
if {$opt_glob_paths eq "*"} {
set matchdirs $okdirs
} else {
set matchdirs [list]
foreach dir $okdirs {
if {$callallbelow || [child_path_state $opt_glob_paths [_tailbase_match_path $opt_tailbase $dir] $callallbelow]} {
lappend matchdirs $dir
}
set matchdirs [list]
foreach dir $okdirs {
if {$callallbelow || [child_path_state $opt_glob_paths [_tailbase_match_path $opt_tailbase $dir] $callallbelow]} {
lappend matchdirs $dir
}
}
@ -1434,7 +1508,7 @@ namespace eval punk::path {
error "treefilenames_zipfs can only be used on paths beginning with [zipfs root] on this systems"
}
set dir [string trimright $opt_dir "/"]
if {[_path_matches_any $opt_exclude_paths [_tailbase_match_path $opt_tailbase $dir]]} {
if {[exclude_state $opt_exclude_paths [_tailbase_match_path $opt_tailbase $dir]] eq "subtree"} {
return [list]
}
set dirlen [string length $dir]
@ -1455,7 +1529,7 @@ namespace eval punk::path {
break
}
if {$superpath ni $dirlist} {
if {[_path_matches_any $opt_exclude_paths [_tailbase_match_path $opt_tailbase $superpath]]} {
if {[exclude_state $opt_exclude_paths [_tailbase_match_path $opt_tailbase $superpath]] eq "subtree"} {
lappend skipdirs $superpath
set skipdir 1
break
@ -1481,10 +1555,15 @@ namespace eval punk::path {
} else {
set match 1
}
if {$match && $opt_glob_paths ne "*"} {
if {$match} {
set file_dir_match [_tailbase_match_path $opt_tailbase [file dirname $finalpart]]
set file_dir_state [directory_state $opt_glob_paths $file_dir_match 0]
set match [dict get $file_dir_state include_files]
if {$match && [exclude_state $opt_exclude_paths $file_dir_match] ne "none"} {
#files-level exclusion of the immediate directory
#(subtree exclusions were pruned via the superpath loop)
set match 0
}
}
if {$match} {
set skipfile 0
@ -1500,7 +1579,7 @@ namespace eval punk::path {
}
} else {
if {$finalpart ni $dirlist} {
if {[_path_matches_any $opt_exclude_paths [_tailbase_match_path $opt_tailbase $finalpart]]} {
if {[exclude_state $opt_exclude_paths [_tailbase_match_path $opt_tailbase $finalpart]] eq "subtree"} {
lappend skipdirs $finalpart
} else {
lappend dirlist $finalpart
@ -1525,9 +1604,10 @@ namespace eval punk::path {
"List of filenames below path.
The resulting list is unsorted.
The path globbing syntax supports *, ** and ? as glob characters in any segment of the path, with the following semantics:
The path globbing syntax supports *, **, *** and ? as glob characters in any segment of the path, with the following semantics:
* matches any single segment in the path
** matches 1 or more segments in the path (so /usr/**/bin will match /usr/x/bin and user/x/y/bin but not /usr/bin )
** as a whole segment matches 1 or more segments in the path (so /usr/**/bin will match /usr/x/bin and user/x/y/bin but not /usr/bin )
*** as a whole segment matches 0 or more segments in the path (so /usr/*** will match /usr itself as well as anything below it)
? matches any single character in a single segment of the path (so /usr/te?t will match /usr/test and /usr/text but not /usr/texxt)
"
-directory -type directory -help\
@ -1546,22 +1626,35 @@ namespace eval punk::path {
"
-sort -type any -default natural -choices {none ascii dictionary natural}
-exclude-paths -default {} -help\
"list of path patterns to exclude
may include * and ** path segments e.g
/usr/** (exclude subfolders based at /usr but not
"list of directory path patterns to exclude
may include *, ** and *** path segments e.g
/usr (exclude files directly within /usr; subfolders
are still walked)
/usr/* (exclude files in folders exactly one below /usr)
/usr/** (exclude the subtrees based at /usr but not
files within /usr itself)
/usr/*** (exclude files within /usr and all its subfolders)
**/_aside (exclude files where _aside is last segment)
**/_aside/* (exclude folders one below an _aside folder)
**/_aside/** (exclude files in all folders with _aside as a segment)"
**/_aside/** (exclude everything below any _aside segment)
Patterns whose final segment is ** or *** prune the matched
subtree; other matches exclude only the files directly in the
matching folder. Exclusion wins over inclusion for the same file."
-exclude-files -default {}
-include-paths -default {**} -help\
"list of path patterns to include
may include * and ** path segments e.g
"list of directory path patterns to include
may include *, ** and *** path segments e.g
/usr (include files directly within /usr only)
/usr/* (include files in folders exactly one below /usr)
/usr/** (include files in subfolders based at /usr but not
files within /usr itself)
/usr/*** (include files within /usr and all its subfolders)
**/_aside (include files where _aside is last segment in the folder)
**/_aside/* (include files in folders one below an _aside folder)
**/_aside/** (include all files in folders with _aside as a segment)"
**/_aside/** (include all files in folders with _aside as a segment)
A directory matching a pattern contributes the files directly within
it; patterns whose final segment is ** or *** also include everything
below their match. The /usr, /usr/*, /usr/** and /usr/*** forms are
deliberately separable."
@values -min 0 -max -1 -optional 1 -type string
tailglobs -default * -multiple 1 -help\
"Patterns to match against filename portion (last segment) of each file path

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

@ -1,5 +1,6 @@
0.3.0
0.4.0
#First line must be a semantic version number
#all other lines are ignored.
#0.4.0 - pathglob syntax: *** as a whole segment matches ZERO or more segments (X/*** = X and everything below; ***/f.txt matches f.txt at any depth incl root) - globmatchpath/pathglob_as_re/treefilenames (G-093); treefilenames include separability fix: an exact or single-segment-glob full match no longer sets allbelow (previously X or X/* dragged the whole subtree in via the deep walk; zipfs walk already separable); treefilenames exclude separability fix: only **|***-tail exclude patterns prune the subtree - other matches exclude just that folder's files and the walk continues below (both walks; previously any dir match pruned, contradicting the **/_aside vs **/_aside/** argdoc distinction); globmatchpath default-nocase driveletter rewrite now a direct regex-head rewrite (handles c:/*** glued-group form, restricts to alpha drives); treefilenames legacy bare-* include collapse removed (a * in -include-paths no longer means match-everything - it is the one-below lattice form; use **|***)
#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

16
src/tests/AGENTS.md

@ -15,15 +15,15 @@ Top-level test harness and source-tree tests for ShellSpy/Punk. Tests here exerc
- `runtests.tcl` is the primary source-tree test entry point; sublevel `all.tcl` files are a legacy pattern and are not required.
- Tests use `tcltest` unless a child AGENTS.md documents a different local harness.
- `runtests.tcl` excludes `AGENTS.md` and `*.tcl` helper files when discovering `.test` files.
- Discovery path targeting (G-093, 2026-07-19): `-include-paths`/`-exclude-paths` patterns match each test file's path relative to `src/tests/` (forward slashes) via `punk::path::globmatchpath``*` within a segment, `?` a single character, `**` spanning segments. `X/**` includes files directly in `X` as well as deeper ones; an exact relative path is a valid single-file pattern; a bare directory path without a glob tail matches nothing (append `/**` for a subtree). Both options accept a space-separated pattern list and accumulate across repeated flags; exclusion wins over inclusion for the same file. Implemented in `testsupport/discovery.tcl` as a post-walk filter over the discovered relative FILE paths — deliberately not punk::path::treefilenames' own directory-oriented `-include-paths`, whose documented `X/**` boundary semantics exclude files directly in `X` and silently missed suites keeping `.test` files at the subtree root (the 2026-07-19 G-092 measurement failures). `-serial-paths` matching was already file-path-oriented and is unchanged. Pinned by `runner/testsuites/discovery/pathdiscovery.test`.
- `-discover-only 1` prints the discovered test-file list and exits before any run machinery starts — subsecond, via the G-093 two-stage boot (only punk::args + punk::path load before the exit; punk/Thread/shellrun/punk::tcltestrun and the testinterp/child path assembly load after). Always emits one machine-readable `RUNTESTS_FILES <path>...` line (a well-formed tcl list with the marker as first element, emitted last), plus one path per line first in non-json report modes. Use it to check targeting before an expensive run.
- Discovery path targeting (G-093, reworked 2026-07-20): `-include-paths`/`-exclude-paths` are passed through to `punk::path::treefilenames` unchanged and carry its directory-oriented pathglob semantics — identical meaning at every layer. Patterns match the DIRECTORY containing a test file (relative to `src/tests/`, forward slashes); the trailing file-tail globs (default `*.test`) select file names — independent axes. Separable segment forms: `X` (files directly in X only), `X/*` (exactly one below), `X/**` (strictly below X — NOT files directly in X), `X/***` (X and everything below — the common subtree form; `***` = zero or more segments, punk::path 0.4.0). Exclusion uses the same syntax: `**`|`***`-tail patterns prune the matched subtree, other matches exclude just that directory's files, and exclusion wins over inclusion for the same file. Single-file targeting = exact directory pattern + file tail. A received include pattern contributing no files earns a stderr advisory (the `X/**`-for-subtree foot-gun; results unaffected). `-serial-paths` uses the same directory-of-file reading (default `modules/punk/console/*** modules/opunk/console/***`). Pinned by `runner/testsuites/discovery/pathdiscovery.test`; the pathglob lattice itself by `modules/punk/path/testsuites/tests/path.test`.
- `-discover-only 1` prints the discovered test-file list and exits before any run machinery starts — subsecond, via the G-093 two-stage boot (only punk::args + punk::path load before the exit — the bootsupport punk::path must be >= 0.4.0 for `***`, enforced by a versioned require in `testsupport/discovery.tcl`; punk/Thread/shellrun/punk::tcltestrun and the testinterp/child path assembly load after). Always emits one machine-readable `RUNTESTS_FILES <path>...` line (a well-formed tcl list with the marker as first element, emitted last), plus one path per line first in non-json report modes. Use it to check targeting before an expensive run.
- `runtests.tcl` defaults tcltest `-tmpdir` (the `makeFile`/`makeDirectory` location) to a fresh directory under the system temp area, deleted before exit, so test helper files never land in the source tree (aborted core exec.test runs previously littered `src/tests`). A `-tmpdir` supplied via `-tcltestoptions` overrides this. Applies to both modes: multi-process mode forwards `-tmpdir` (and all other tcltest options) to child processes via the generated environment payload.
- The testinterp auto_path (replaced wholesale) also includes the running kit's internal `lib`/`lib_tcl<N>` trees (zipfs app mount, tclkit `::tcl::kitpath`, cookfs `//cookit:/` — mirroring `src/vfs/_config/punk_main.tcl`), so kit-bundled packages (e.g. tcllib's `tcl::chan::fifo2`, needed by the shellrun harness) resolve when the project tree doesn't supply them. `tclPkgUnknown` scans only an entry plus immediate children, so the kit lib dirs must be explicit entries. No-op under a native tclsh.
- The single-process testinterp runs `package prefer latest` so alpha-versioned dev modules (`999999.0a1.0`) are preferred over stable bootsupport/vendored copies on unversioned `package require`.
- The testinterp module path includes `src/vendormodules` (and `src/vendormodules_tcl<major>` when present) so vendored dependencies such as `voo` resolve in tests.
- Multi-process mode (`-tcltestoptions {-singleproc 0}`, completed 2026-07-18): each test file runs in a child process of the same executable via `testsupport/child_test_runner.tcl`, driven by a per-run environment payload `runtests.tcl` generates (package prefer latest, test tm paths, auto_path, modpod ifneeded definitions, tcltest options; per-file `-testdir` computed child-side). Children are LEAN (2026-07-19 cleanup): they load only tcltest plus whatever each test file declares - the previous shellrun-preload + runx-warmup mirror of the testinterp was removed after 17 files gained their missing explicit `package require` lines (the contract above always required them; the parity tool pinpointed the violators, and an empirical lean run caught the final straggler - examples.test needing textblock because punk::args only soft-requires it). The single-process testinterp still preloads shellrun, but as the RUNNER's capture mechanism (runx -tcl sourcing) - tests must not rely on it. Known module-web finding from the sweep: punk::ansi and punk::args call punk::lib internally without requiring it, and a hard back-require is blocked by punk::lib's own hard require of punk::args (cycle) - dependencies are therefore declared at test level (grepstr/untabify/examples carry comments); a considered module-level resolution remains open. Watch-mode interaction notes live in child_test_runner.tcl (none today - the parent relays; a future live per-child tee must be conditional on the env var so unwatched children stay lean). The bootstrap warms `clock format` before the module-path wipe: first script-level clock use loads msgcat from the runtime's default module paths, which the test module paths do not supply (the single-process testinterp is shielded only by the runtests parent's earlier clock use - a latent gap for any test explicitly requiring msgcat in either mode).
- Multi-process failure classification: a nonzero child exit is reported as a file-level failure (compact/markdown/json carry an ERROR entry with errorcode `CHILDPROCESS exit <n>` and the child's stderr tail); exit-0 with no tcltest summary line remains the `missing-cleanupTests` warning. Prefer a native tclsh for multi-process runs: children of a kit executable boot via the kit's script dispatch with kit-stamped punk packages preloaded, which can shadow the src dev modules under test (the runner prints a warning).
- Parallel jobs (G-091, achieved 2026-07-19 - see goals/archive/G-091-runtests-parallel-jobs.md): `-jobs N` (N > 1) implies multi-process mode and runs test files through a worker pool of N concurrent child processes (tpool; per-child capture files and per-child tcltest `-tmpdir` subdirs so concurrent `makeFile` helpers cannot collide; longest-first submission using static weights from the 2026-07-18/19 timing measurements - the full-suite floor is the slowest single child; the former ~2m multishell.test floor was removed by the G-092 split of that suite into four files, leaving all children under ~35s). Files matching `-serial-paths` run sequentially after the parallel batch - default `{modules/punk/console/** modules/opunk/console/**}` (the console-state suites, which emit to and query the shared terminal; suites driven through piped-stdio child processes - shell/**, modules/punk/repl/** - stay parallel by default and can be added to `-serial-paths` if they prove flaky under a real console; each serial file costs its full ~3-4s child boot strictly after the parallel phase, so the list is deliberately minimal). Reports are unchanged in shape and emitted in discovery order. An explicit `-tcltestoptions {-singleproc 1}` conflicts with `-jobs > 1` (error). Watch-event semantics under `-jobs`: no `file-start` events; `file-collected` (with exitcode, output relayed at that moment) fires in completion order and the authoritative `file-end` (tallies) in discovery order. Per-test microsecond timings are noisier under CPU oversubscription - prefer sequential runs when `-slowest` matters. Child spawn under -jobs is background-exec plus `tcl::process status` polling: concurrent BLOCKING execs from worker threads convoy on Windows - they all return together at the longest-lived concurrent child's exit (proven 2026-07-19 with plain sleepers of 2-12s all walling at 12s; this cost early -jobs runs ~50s of false critical path). Runtimes without `tcl::process` (tcl 8.6) fall back to blocking exec, which re-convoys under -jobs. The runner prints phase times (parallel/serial-tail/processing) and the ten slowest child WALL times in non-json modes - child wall includes boot and file-level fixture cost that per-test usec timings miss.
- Parallel jobs (G-091, achieved 2026-07-19 - see goals/archive/G-091-runtests-parallel-jobs.md): `-jobs N` (N > 1) implies multi-process mode and runs test files through a worker pool of N concurrent child processes (tpool; per-child capture files and per-child tcltest `-tmpdir` subdirs so concurrent `makeFile` helpers cannot collide; longest-first submission using static weights from the 2026-07-18/19 timing measurements - the full-suite floor is the slowest single child; the former ~2m multishell.test floor was removed by the G-092 split of that suite into four files, leaving all children under ~35s). Files matching `-serial-paths` run sequentially after the parallel batch - default `{modules/punk/console/*** modules/opunk/console/***}` (directory-of-file matching as of G-093; the console-state suites, which emit to and query the shared terminal; suites driven through piped-stdio child processes - shell/***, modules/punk/repl/*** - stay parallel by default and can be added to `-serial-paths` if they prove flaky under a real console; each serial file costs its full ~3-4s child boot strictly after the parallel phase, so the list is deliberately minimal). Reports are unchanged in shape and emitted in discovery order. An explicit `-tcltestoptions {-singleproc 1}` conflicts with `-jobs > 1` (error). Watch-event semantics under `-jobs`: no `file-start` events; `file-collected` (with exitcode, output relayed at that moment) fires in completion order and the authoritative `file-end` (tallies) in discovery order. Per-test microsecond timings are noisier under CPU oversubscription - prefer sequential runs when `-slowest` matters. Child spawn under -jobs is background-exec plus `tcl::process status` polling: concurrent BLOCKING execs from worker threads convoy on Windows - they all return together at the longest-lived concurrent child's exit (proven 2026-07-19 with plain sleepers of 2-12s all walling at 12s; this cost early -jobs runs ~50s of false critical path). Runtimes without `tcl::process` (tcl 8.6) fall back to blocking exec, which re-convoys under -jobs. The runner prints phase times (parallel/serial-tail/processing) and the ten slowest child WALL times in non-json modes - child wall includes boot and file-level fixture cost that per-test usec timings miss.
- UDP watch mode (added 2026-07-18): `env(PUNK_TEST_UDPTEE)` = `<port>` or `<host>:<port>` (e.g `41197`) makes runtests emit the run over UDP via the shellfilter::log mechanism (shellthread worker threads -> tcludp, one datagram per line, cooked format whose source column carries the tag). Tag `runtests` carries lifecycle events; each event message is a well-formed tcl list `RUNTESTS-EVENT <eventname> ?key value ...?` (`run-start`, `file-start`, `file-end` with per-file tallies, `run-end`). Tags `teststdout`/`teststderr` carry test file output: streamed live per output chunk in single-process mode (`shellrun::runx -teelog` 0.2.0 - write traces on the capture variables, so captured results and reports are unchanged) and relayed per completed file by the parent in multi-process mode. No listener is required (sends to an unbound local port are discarded). The parent extends its auto_path with `<projectroot>/lib_tcl<N>/<arch>` so log workers resolve the project's tcludp 1.0.13+ (1.0.12's UDP_ExitProc is the G-036 exit-hang class). Viewer-side caveat: tcludp's RECEIVE fileevent under Tcl 9.0.3/Windows delivered only the first datagram in testing (send side unaffected; a .NET UdpClient received everything) - a viewer should use timer-driven non-blocking reads or a non-tcludp receiver until that is resolved.
- In watch mode the singleproc loop closes and terminates the shellfilter::log worker threads opened inside each per-file testinterp before deleting it (the `-teelog` tag workers plus shellrun's punkshout/punksherr teehandle workers). The terminate handshakes cost ~0.5-0.7s per file, so unwatched runs skip this and keep the pre-existing small leak of the two teehandle worker threads per file (noted for the shellfilter/shellthread audit; the process exits at run end regardless).
- Tests should run against source modules and libraries from `src/`, not installed packages or root-level build outputs.
@ -42,17 +42,17 @@ Top-level test harness and source-tree tests for ShellSpy/Punk. Tests here exerc
- Select an appropriate interpreter, such as `tclsh`, `tclsh90`, or a built Punk executable such as `punk902z`.
- Run the full source-tree suite with `<tcl_interpreter> src/tests/runtests.tcl`.
- Use `-include-paths` with glob patterns matched against test-file paths relative to `src/tests/`, forward slashes — e.g `modules/punk/args/**`; an exact relative path targets a single file, and as of G-093 `X/**` also matches files sitting directly in `X`. The flag accepts a space-separated list of patterns and may also be repeated, with all occurrences accumulating (`-multiple 1` as of 2026-07-10; previously the last flag silently won).
- Use `-exclude-paths` (same syntax and accumulation) to remove files from the included set — exclusion wins for the same file. It gives broad runs a middle ground between full and targeted: e.g `-exclude-paths shell/**` when no built kit is present, or skipping a heavy suite family unrelated to the change.
- Use `-include-paths` with directory glob patterns relative to `src/tests/`, forward slashes — `modules/punk/args/***` runs that whole subtree (`X/***` deliberately excludes files directly in `X`; bare `X` is files-directly-in-X only). The flag accepts a space-separated list of patterns and may also be repeated, with all occurrences accumulating (`-multiple 1` as of 2026-07-10; previously the last flag silently won). Single-file targeting: `-include-paths modules/punk/args/testsuites/args dynamic.test`.
- Use `-exclude-paths` (same syntax and accumulation) to remove directories from discovery — `**`|`***` tails prune the matched subtree, other matches drop only that directory's files, and exclusion wins for the same file. It gives broad runs a middle ground between full and targeted: e.g `-exclude-paths shell/***` when no built kit is present, or skipping a heavy suite family unrelated to the change.
- Verify targeting cheaply before an expensive run with `-discover-only 1` (prints the discovered file list and exits, subsecond).
- Use `-tcltestoptions {-match <test_name>}` for focused single-test runs.
- For agent-efficient focused runs, prefer `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -include-paths <relative/path/**> <file-tail.test>`.
- For agent-efficient focused runs, prefer `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -include-paths <relative/path/***> <file-tail.test>`.
- Multiple trailing file tails are supported and match independently: `runtests.tcl foo.test bar.test` runs both in one invocation (fixed 2026-07-17; previously multiple names collapsed into one glob matching zero files).
- Treat `RUNTESTS_RESULT status=warn` and compact warning reasons such as `missing-cleanupTests` as incomplete test results, even if observed pass events are listed.
- Add `-slowest <n>` when timing outliers are relevant.
- Multi-process runs use `-tcltestoptions {-singleproc 0}` with otherwise identical flags. Check result parity between modes by capturing `-report json` stdout from each and comparing with `tclsh scriptlib/developer/runtests_parity.tcl <a.json> <b.json>` (ignores timings; exit 0 on parity).
- To watch runs live (including runs launched by agents or other processes, which inherit the environment): set `PUNK_TEST_UDPTEE=41197` and point a UDP listener/viewer at that port. No runner flags needed; results are unaffected.
- For broad runs use `-jobs` - e.g `<tcl_interpreter> src/tests/runtests.tcl -jobs 16 -report compact -show-passes 0`. Reference-machine full-suite scaling (2026-07-19, post G-092 split + lean-children cleanup, result parity verified at each level): singleproc sequential ~5m40s, multiproc sequential ~4m30s, `-jobs 16` ~50s (~6.8x), with diminishing returns past 16 - the wall decomposes into the slowest single child (~30s, the runtimecmd pair - single tests, not further splittable), the serial console tail (a few seconds piped now children boot lean; larger under a real console where those suites actually run), and runner overhead (~10s). `-jobs 16` is the reference-machine sweet spot. Use `-jobs` for ANY run spanning more than a handful of test files - subtree runs count (e.g `-include-paths modules/punk/args/**` is 28 files: ~50s sequential vs ~11s at `-jobs 16`); only single-file or few-file runs gain nothing from it.
- For broad runs use `-jobs` - e.g `<tcl_interpreter> src/tests/runtests.tcl -jobs 16 -report compact -show-passes 0`. Reference-machine full-suite scaling (2026-07-19, post G-092 split + lean-children cleanup, result parity verified at each level): singleproc sequential ~5m40s, multiproc sequential ~4m30s, `-jobs 16` ~50s (~6.8x), with diminishing returns past 16 - the wall decomposes into the slowest single child (~30s, the runtimecmd pair - single tests, not further splittable), the serial console tail (a few seconds piped now children boot lean; larger under a real console where those suites actually run), and runner overhead (~10s). `-jobs 16` is the reference-machine sweet spot. Use `-jobs` for ANY run spanning more than a handful of test files - subtree runs count (e.g `-include-paths modules/punk/args/***` is 28 files: ~50s sequential vs ~11s at `-jobs 16`); only single-file or few-file runs gain nothing from it.
- Add `-strict-exit 1` when a nonzero shell exit code is needed for failures or parser warnings.
- Capture enough stderr or failure context to identify the failing command or assertion.
- For ERROR-status failures, the markdown report's `errorInfo` block and compact `message=` field carry the full Tcl error message; use `-report markdown` for untruncated context.
@ -61,7 +61,7 @@ Top-level test harness and source-tree tests for ShellSpy/Punk. Tests here exerc
## Verification
- `<tcl_interpreter> src/tests/runtests.tcl` passes when broad source-tree test coverage is relevant.
- Focused checks use `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -include-paths <relative/path/**>` and optional file-tail globs.
- Focused checks use `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -include-paths <relative/path/***>` and optional file-tail globs.
- Changes to `runtests.tcl` or `testsupport/child_test_runner.tcl` are verified by running the full suite in both modes with `-report json` and confirming `scriptlib/developer/runtests_parity.tcl` reports `PARITY: ok`.
- Documentation-only changes are verified by reviewing the affected DOX chain and diff.

4
src/tests/core/AGENTS.md

@ -18,12 +18,12 @@ Selected tests taken from or modeled after Tcl core tests. These tests should pr
## Work Guidance
- Use `src/tests/runtests.tcl` from the repository root for source-tree harness runs.
- Prefer focused `-report compact -show-passes 0 -include-paths core/**` checks when editing only this subtree.
- Prefer focused `-report compact -show-passes 0 -include-paths core/***` checks when editing only this subtree.
- Document any deliberate divergence from standard Tcl behavior in the nearest relevant test file or child AGENTS.md.
## Verification
- `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -include-paths core/**` passes for changes in this subtree.
- `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -include-paths core/***` passes for changes in this subtree.
- Compare results with standard `tclsh` and the target Punk executable when compatibility behavior is the subject of the change.
- Known baselines (2026-07-09): under a native `tclsh` (9.0.x/9.1) exactly one failure is expected — `exec-14.3`. Under a built Punk executable (`punk902z`, `punk91`) two additional exec.test failures (`exec-16.2`, `exec-17.1`) and ~22 extra skips appear because the tests re-exec `[interpreter]` (a full punkshell exe whose boot touches stdio) with redirected/closed standard channels; these are punk-exe artifacts, identical on Tcl 9.0.2 and 9.1, not core incompatibilities.

4
src/tests/core/tcl/testsuites/tests/AGENTS.md

@ -16,11 +16,11 @@ A selection of unit tests taken from or modeled after the Tcl core distribution.
## Work Guidance
- Use focused runs through `src/tests/runtests.tcl` with `-report compact -show-passes 0 -include-paths core/tcl/testsuites/tests/**`.
- Use focused runs through `src/tests/runtests.tcl` with `-report compact -show-passes 0 -include-paths core/tcl/testsuites/tests/***`.
## Verification
- `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -include-paths core/tcl/testsuites/tests/**` passes for changes in this directory.
- `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -include-paths core/tcl/testsuites/tests/***` passes for changes in this directory.
## Child DOX Index

6
src/tests/modules/AGENTS.md

@ -29,9 +29,9 @@ Unit tests for editable source modules under `src/modules/`, `src/modules_tcl8/`
## Verification
- Run targeted module tests with `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -include-paths modules/<namespacepath>/**`.
- Run a single test by name with `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -tcltestoptions {-match <test_name>} -include-paths modules/<namespacepath>/**`.
- Run a specific test file by adding its file tail, such as `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -include-paths modules/punk/args/** parsekey.test`.
- Run targeted module tests with `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -include-paths modules/<namespacepath>/***`.
- Run a single test by name with `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -tcltestoptions {-match <test_name>} -include-paths modules/<namespacepath>/***`.
- Run a specific test file by adding its file tail, such as `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -include-paths modules/punk/args/*** parsekey.test`.
- Use `-report markdown+json` when both detailed human-readable failure sections and a structured summary are useful.
- If a compact result shows `reason=missing-cleanupTests`, add the missing `tcltest::cleanupTests` before trusting observed pass events.

82
src/tests/modules/punk/path/testsuites/tests/path.test

@ -95,6 +95,88 @@ namespace eval ::testspace {
} \
-result {1 0 0}
#added 2026-07-20 (agent, G-093)
test globmatchpath_zom_segments {*** as a whole segment matches zero or more path segments} \
-setup $common -body {
set result [list \
[punk::path::globmatchpath /usr/*** /usr] \
[punk::path::globmatchpath /usr/*** /usr/lib] \
[punk::path::globmatchpath /usr/*** /usr/lib/deep/x.txt] \
[punk::path::globmatchpath /usr/*** /usrx] \
[punk::path::globmatchpath ***/f.txt f.txt] \
[punk::path::globmatchpath ***/f.txt a/b/f.txt] \
[punk::path::globmatchpath a/***/b a/b] \
[punk::path::globmatchpath a/***/b a/x/y/b] \
[punk::path::globmatchpath a/***/b a] \
[punk::path::globmatchpath *** ""] \
[punk::path::globmatchpath *** a/b/c] \
]
} \
-cleanup {
} \
-result {1 1 1 0 1 1 1 1 0 1 1}
test globmatchpath_zom_driveletter_case {Windows drive letter stays case insensitive by default for *** patterns (regex head rewrite)} \
-setup $common -body {
set result [list \
[punk::path::globmatchpath C:/etc/*** c:/etc] \
[punk::path::globmatchpath c:/etc/*** C:/etc/sub/x.txt] \
[punk::path::globmatchpath c:/Etc/*** c:/etc] \
]
} \
-cleanup {
} \
-result {1 1 0}
test treefilenames_include_lattice_separability {in-dir (X), one-below (X/*), subtree-below (X/**) and self-and-below (X/***) include forms are separable} \
-setup $treefilenames_tree -body {
set r_exact [lsort [punk::path::treefilenames -sort none -directory $tf_tree_root -tailbase $tf_tree_root -include-paths {src} *.txt]]
set r_one [lsort [punk::path::treefilenames -sort none -directory $tf_tree_root -tailbase $tf_tree_root -include-paths {src/*} *.txt]]
set r_below [lsort [punk::path::treefilenames -sort none -directory $tf_tree_root -tailbase $tf_tree_root -include-paths {src/**} *.txt]]
set r_zom [lsort [punk::path::treefilenames -sort none -directory $tf_tree_root -tailbase $tf_tree_root -include-paths {src/***} *.txt]]
set result [list \
[expr {$r_exact eq [list [file join src srcroot.txt]]}] \
[expr {$r_one eq [list [file join src vfs vfs.txt]]}] \
[expr {$r_below eq [lsort [list [file join src vfs deep deep.txt] [file join src vfs vfs.txt]]]}] \
[expr {$r_zom eq [lsort [list [file join src srcroot.txt] [file join src vfs deep deep.txt] [file join src vfs vfs.txt]]]}] \
]
} \
-cleanup $treefilenames_cleanup \
-result {1 1 1 1}
test treefilenames_include_zom_subsumes_boundary_pair {X/*** equals the {X/** X} two-pattern composition} \
-setup $treefilenames_tree -body {
set r_zom [lsort [punk::path::treefilenames -sort none -directory $tf_tree_root -tailbase $tf_tree_root -include-paths {**/subdir/***} *.txt]]
set r_pair [lsort [punk::path::treefilenames -sort none -directory $tf_tree_root -tailbase $tf_tree_root -include-paths {**/subdir/** **/subdir} *.txt]]
set expected [lsort [list \
[file join alpha subdir deep deep.txt] \
[file join alpha subdir direct.txt] \
]]
set result [list [expr {$r_zom eq $expected}] [expr {$r_zom eq $r_pair}]]
} \
-cleanup $treefilenames_cleanup \
-result {1 1}
test treefilenames_exclude_lattice_separability {exact exclude forms drop only their own files while **|*** tails prune the subtree} \
-setup $treefilenames_tree -body {
set base [list -sort none -directory $tf_tree_root -tailbase $tf_tree_root]
set all [lsort [punk::path::treefilenames {*}$base *.txt]]
set r_exact [lsort [punk::path::treefilenames {*}$base -exclude-paths {src} *.txt]]
set r_below [lsort [punk::path::treefilenames {*}$base -exclude-paths {src/**} *.txt]]
set r_zom [lsort [punk::path::treefilenames {*}$base -exclude-paths {src/***} *.txt]]
set srcroot [file join src srcroot.txt]
set vfs [file join src vfs vfs.txt]
set deep [file join src vfs deep deep.txt]
set result [list \
[expr {$srcroot ni $r_exact && $vfs in $r_exact && $deep in $r_exact}] \
[expr {$srcroot in $r_below && $vfs ni $r_below && $deep ni $r_below}] \
[expr {$srcroot ni $r_zom && $vfs ni $r_zom && $deep ni $r_zom}] \
[expr {[llength $all] - [llength $r_zom] == 3}] \
]
} \
-cleanup $treefilenames_cleanup \
-result {1 1 1 1}
test subfolders_exclude_trailing_doublestar {Trailing /** prunes descendants but keeps the matching base directory} \
-setup $subfolders_tree -body {
set result [lsort [punk::path::subfolders -recursive -exclude-paths {**/src/**} .]]

4
src/tests/runner/AGENTS.md

@ -11,13 +11,13 @@ Test suites for the `src/tests/runtests.tcl` harness itself (discovery/path-targ
## Local Contracts
- Suites here may exec `runtests.tcl -discover-only 1` child processes of `[info nameofexecutable]` (subsecond since the G-093 two-stage boot — discovery loads only punk::args + punk::path before the early exit). Keep such children cheap and few: a suite file's child-process cost lands in its `-jobs` child wall.
- `testsuites/discovery/pathdiscovery.test` is the G-093 runner-targeting regression suite: it pins the `-include-paths`/`-exclude-paths` file-relpath matching semantics at three levels — pure `runtests_discovery::filter_paths`, composed `discover_testfiles` over a fixture tree, and end-to-end `-discover-only` runs against the real test tree, including the two 2026-07-19 G-092 measurement failures as characterization cases.
- `testsuites/discovery/pathdiscovery.test` is the G-093 runner-targeting regression suite: it pins the directory-oriented `-include-paths`/`-exclude-paths` semantics (the separable `X` / `X/*` / `X/**` / `X/***` lattice, passed through to punk::path::treefilenames) at three levels — the `runtests_discovery::dir_matches_any` classifier (also used by `-serial-paths` and the zero-match advisory), composed `discover_testfiles` over a fixture tree, and end-to-end `-discover-only` runs against the real test tree, including the G-092 measurement set in its corrected `X/***` spelling and the `X/**` zero-match advisory case.
- Fixture trees go under tcltest's `temporaryDirectory` (the runner's run-scoped `-tmpdir`), never into the source tree.
- Suites here must stay parallel-safe (no console interaction) — they are not in `-serial-paths`.
## Work Guidance
- Run: `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -include-paths runner/**`
- Run: `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -include-paths runner/***`
- LF line endings (per root AGENTS.md user preferences).
## Verification

203
src/tests/runner/testsuites/discovery/pathdiscovery.test

@ -1,32 +1,30 @@
# -*- tcl -*-
# Regression suite for runtests.tcl path-glob discovery targeting (G-093):
# the -include-paths/-exclude-paths semantics implemented by
# testsupport/discovery.tcl (runtests_discovery::filter_paths and
# runtests_discovery::discover_testfiles), plus end-to-end
# 'runtests.tcl -discover-only' child runs against the real test tree,
# including the two 2026-07-19 G-092 measurement failures as characterization
# cases (a four-pattern single value that matched only one subtree, and
# repeated -include-paths flags that matched zero files).
# the directory-oriented -include-paths/-exclude-paths semantics passed through to
# punk::path::treefilenames (testsupport/discovery.tcl), the -serial-paths
# directory-of-file classifier, and end-to-end 'runtests.tcl -discover-only' child
# runs against the real test tree - including the G-092 measurement set as
# characterization cases in their corrected X/*** spelling.
#
# Contract pinned here (G-093 root cause: punk::path::treefilenames'
# directory-oriented -include-paths excluded files directly at an X/**
# boundary, so suites keeping .test files at the subtree root were silently
# missed; runtests now filters the tailbase-relative FILE paths instead):
# 1. Patterns match each test file's path relative to the test base
# (forward slashes): * within a segment, ? single char, ** spans segments.
# 2. X/** includes files directly in X as well as deeper ones.
# 3. An exact relative path is a valid single-file pattern; a bare directory
# path without a glob tail matches nothing.
# 4. A single -include-paths value may carry multiple space-separated
# patterns; repeated flags accumulate; both forms are equivalent.
# 5. -exclude-paths uses the same syntax and accumulation semantics;
# exclusion wins over inclusion for the same file.
# 6. File-tail globs (trailing positional args) compose with path patterns.
# Contract pinned here (G-093; punk::path 0.4.0 *** + separability fixes):
# 1. Patterns match the DIRECTORY containing a test file, relative to the test
# base (forward slashes); trailing file-tail globs (default *.test) select
# file NAMES - the two axes are independent.
# 2. Separable lattice: X (in-dir only), X/* (exactly one below), X/**
# (strictly below, NOT files directly in X), X/*** (X and everything below).
# 3. A single -include-paths value may carry multiple space-separated patterns;
# repeated flags accumulate; both forms are equivalent.
# 4. -exclude-paths uses the same syntax; **|***-tail patterns prune the matched
# subtree, other matches exclude just that directory's files; exclusion wins
# over inclusion for the same file.
# 5. Single-file targeting = directory pattern + file tail.
# 6. A received include pattern contributing no files earns a stderr advisory
# (the X/** vs X/*** foot-gun), with results unaffected.
#
# Run: tclsh src/tests/runtests.tcl -report compact -show-passes 0 -include-paths runner/** pathdiscovery.test
# Run: tclsh src/tests/runtests.tcl -report compact -show-passes 0 -include-paths runner/***
package require tcltest
package require punk::path
package require punk::path 0.4.0-
namespace eval ::testspace {
namespace import ::tcltest::*
@ -39,16 +37,20 @@ namespace eval ::testspace {
#the runner's own discovery exclude-files list (mirrored from runtests.tcl)
variable runner_exclude_files {AGENTS.md *.tcl}
proc runtests_discovered {args} {
proc runtests_discover_raw {args} {
#run runtests.tcl -discover-only in a child process of this interpreter's
#executable and return the discovered relative-path list parsed from the
#RUNTESTS_FILES line (a well-formed tcl list with the marker as its first
#element). stderr is merged - parent boot noise lines are tolerated.
#executable and return the merged stdout+stderr output
variable testbase
set out [exec [info nameofexecutable] [file join $testbase runtests.tcl] -discover-only 1 {*}$args 2>@1]
return [exec [info nameofexecutable] [file join $testbase runtests.tcl] -discover-only 1 {*}$args 2>@1]
}
proc runtests_discovered {args} {
#discovered relative-path list parsed from the RUNTESTS_FILES line (a well-formed
#tcl list with the marker as its first element). Boot noise lines are tolerated.
set out [runtests_discover_raw {*}$args]
foreach ln [split $out \n] {
set ln [string trimright $ln \r]
if {[string match {RUNTESTS_FILES *} $ln]} {
if {[string match {RUNTESTS_FILES*} $ln]} {
return [lrange $ln 1 end]
}
}
@ -72,10 +74,10 @@ namespace eval ::testspace {
# fixbase/direct.test (file directly at the base)
# fixbase/helper.tcl (must be dropped by the *.tcl exclude-files rule)
# fixbase/AGENTS.md (must be dropped by the AGENTS.md exclude-files rule)
# fixbase/sub1/x.test (file directly at an X/** pattern boundary)
# fixbase/sub1/x.test (file directly at a pattern-base dir; sub1 has no subdirs)
# fixbase/sub1/x.other (tail-glob discrimination)
# fixbase/sub2/deep/y.test (file below a deeper boundary)
# fixbase/sub2/other.test
# fixbase/sub2/deep/y.test (file one below sub2)
# fixbase/sub2/other.test (file directly in sub2)
# -------------------------------------------------------------------------
variable fixture_error ""
variable fixbase ""
@ -105,84 +107,95 @@ namespace eval ::testspace {
}
proc fixdiscover {includes excludes {tails {*.test}}} {
#composed walk+filter over the fixture tree, result sorted for
#composed treefilenames discovery over the fixture tree, result sorted for
#order-independent comparison
variable fixbase
variable runner_exclude_files
lsort [runtests_discovery::discover_testfiles $fixbase $includes $excludes $tails $runner_exclude_files]
}
#added 2026-07-19 (agent, G-093)
test pathdiscovery-1.0 {filter_paths: single value carrying multiple patterns includes every pattern's matches} -body {
runtests_discovery::filter_paths {a/f.test b/g.test c/h.test} {a/** b/**} {}
} -result {a/f.test b/g.test}
test pathdiscovery-1.1 {filter_paths: a literal ** include pattern matches every path} -body {
runtests_discovery::filter_paths {direct.test a/b/c.test} {**} {}
} -result {direct.test a/b/c.test}
test pathdiscovery-1.2 {filter_paths: exclusion wins over inclusion for the same file} -body {
runtests_discovery::filter_paths {a/f.test a/g.test} {a/**} {a/g.test}
} -result {a/f.test}
test pathdiscovery-1.3 {filter_paths: a literal ** exclude pattern removes everything} -body {
runtests_discovery::filter_paths {a/f.test b/g.test} {**} {**}
} -result {}
test pathdiscovery-1.4 {filter_paths: a bare directory-like pattern matches no file paths} -body {
runtests_discovery::filter_paths {a/f.test} {a} {}
} -result {}
#added 2026-07-19 (agent, G-093); reworked 2026-07-20 (agent, G-093) to the
#directory-oriented pass-through semantics with punk::path 0.4.0 *** support
test pathdiscovery-1.0 {dir_matches_any: directory-of-file lattice classification} -body {
list \
[runtests_discovery::dir_matches_any {a/***} a/f.test] \
[runtests_discovery::dir_matches_any {a/***} a/b/f.test] \
[runtests_discovery::dir_matches_any {a/**} a/f.test] \
[runtests_discovery::dir_matches_any {a/**} a/b/f.test] \
[runtests_discovery::dir_matches_any {a} a/f.test] \
[runtests_discovery::dir_matches_any {a} a/b/f.test] \
[runtests_discovery::dir_matches_any {a/*} a/b/f.test] \
[runtests_discovery::dir_matches_any {a/*} a/f.test]
} -result {1 1 0 1 1 0 1 0}
test pathdiscovery-1.1 {dir_matches_any: a file directly at the base has the empty relative directory} -body {
list \
[runtests_discovery::dir_matches_any {***} direct.test] \
[runtests_discovery::dir_matches_any {**} direct.test] \
[runtests_discovery::dir_matches_any {sub1/***} direct.test]
} -result {1 1 0}
test pathdiscovery-1.2 {dir_matches_any: serial default form matches console files at the suite directory} -body {
list \
[runtests_discovery::dir_matches_any {modules/punk/console/***} modules/punk/console/console.test] \
[runtests_discovery::dir_matches_any {modules/punk/console/***} modules/punk/console/sub/deeper.test] \
[runtests_discovery::dir_matches_any {modules/punk/console/***} modules/punk/consolex/other.test]
} -result {1 1 0}
test pathdiscovery-2.0 {fixture: default ** discovers every .test file at all depths} -constraints g093fixture -body {
fixdiscover {**} {}
} -result {direct.test sub1/x.test sub2/deep/y.test sub2/other.test}
test pathdiscovery-2.1 {fixture: X/** includes a file directly at the pattern boundary (G-093 regression)} -constraints g093fixture -body {
test pathdiscovery-2.1 {fixture: X/** deliberately excludes files directly in X (separability - use X/*** for the subtree)} -constraints g093fixture -body {
fixdiscover {sub1/**} {}
} -result {}
test pathdiscovery-2.2 {fixture: X/*** includes files directly in X} -constraints g093fixture -body {
fixdiscover {sub1/***} {}
} -result {sub1/x.test}
test pathdiscovery-2.2 {fixture: deeper X/** boundary} -constraints g093fixture -body {
fixdiscover {sub2/deep/**} {}
} -result {sub2/deep/y.test}
test pathdiscovery-2.3 {fixture: include lattice over a two-level subtree - X, X/*, X/**, X/***} -constraints g093fixture -body {
list \
[fixdiscover {sub2} {}] \
[fixdiscover {sub2/*} {}] \
[fixdiscover {sub2/**} {}] \
[fixdiscover {sub2/***} {}]
} -result {sub2/other.test sub2/deep/y.test sub2/deep/y.test {sub2/deep/y.test sub2/other.test}}
test pathdiscovery-2.3 {fixture: single value carrying two patterns includes both subtrees} -constraints g093fixture -body {
fixdiscover {sub1/** sub2/**} {}
test pathdiscovery-2.4 {fixture: single value carrying two *** patterns includes both subtrees} -constraints g093fixture -body {
fixdiscover {sub1/*** sub2/***} {}
} -result {sub1/x.test sub2/deep/y.test sub2/other.test}
test pathdiscovery-2.4 {fixture: a bare directory path matches nothing (append /** for a subtree)} -constraints g093fixture -body {
fixdiscover {sub1} {}
} -result {}
test pathdiscovery-2.5 {fixture: an exact relative path targets a single file} -constraints g093fixture -body {
fixdiscover {sub1/x.test} {}
} -result {sub1/x.test}
test pathdiscovery-2.6 {fixture: explicit file-tail glob composes with a path pattern} -constraints g093fixture -body {
fixdiscover {sub1/**} {} {x.other}
test pathdiscovery-2.5 {fixture: explicit file-tail glob composes with a directory pattern} -constraints g093fixture -body {
fixdiscover {sub1/***} {} {x.other}
} -result {sub1/x.other}
test pathdiscovery-2.7 {fixture: multiple file-tail globs match independently under a path pattern} -constraints g093fixture -body {
fixdiscover {sub1/**} {} {*.test *.other}
test pathdiscovery-2.6 {fixture: multiple file-tail globs match independently under a directory pattern} -constraints g093fixture -body {
fixdiscover {sub1/***} {} {*.test *.other}
} -result {sub1/x.other sub1/x.test}
test pathdiscovery-2.8 {fixture: exclude a subtree from a full run} -constraints g093fixture -body {
fixdiscover {**} {sub2/**}
test pathdiscovery-2.7 {fixture: exclude a subtree from a full run} -constraints g093fixture -body {
fixdiscover {**} {sub2/***}
} -result {direct.test sub1/x.test}
test pathdiscovery-2.9 {fixture: exclusion wins inside an included subtree} -constraints g093fixture -body {
fixdiscover {sub2/**} {sub2/deep/**}
test pathdiscovery-2.8 {fixture: subtree exclusion wins inside an included subtree} -constraints g093fixture -body {
fixdiscover {sub2/***} {sub2/deep/***}
} -result {sub2/other.test}
test pathdiscovery-2.10 {fixture: exact-path exclusion beats an including pattern} -constraints g093fixture -body {
fixdiscover {sub1/**} {sub1/x.test}
test pathdiscovery-2.9 {fixture: files-level exclusion (no spanning tail) beats an including pattern for that directory} -constraints g093fixture -body {
fixdiscover {sub1/***} {sub1}
} -result {}
test pathdiscovery-2.10 {fixture: files-level exclusion of the base keeps deeper files (walk continues below)} -constraints g093fixture -body {
fixdiscover {sub2/***} {sub2}
} -result {sub2/deep/y.test}
test pathdiscovery-2.11 {fixture: exclude-files rules drop AGENTS.md and *.tcl helpers regardless of tails} -constraints g093fixture -body {
fixdiscover {**} {} {*}
} -result {direct.test sub1/x.other sub1/x.test sub2/deep/y.test sub2/other.test}
test pathdiscovery-3.0 {end-to-end: the G-092 four-pattern single value discovers files from all four subtrees (2026-07-19 failure (a))} -body {
set files [runtests_discovered -include-paths {modules/punk/mix/testsuites/scriptwrap/** shell/testsuites/binscripts/** modules/argparsingtest/** shell/testsuites/punkexe/**}]
test pathdiscovery-3.0 {end-to-end: the G-092 four-subtree set in X/*** spelling discovers files from all four subtrees} -body {
set files [runtests_discovered -include-paths {modules/punk/mix/testsuites/scriptwrap/*** shell/testsuites/binscripts/*** modules/argparsingtest/*** shell/testsuites/punkexe/***}]
set result [list]
foreach prefix {
modules/punk/mix/testsuites/scriptwrap
@ -195,15 +208,15 @@ namespace eval ::testspace {
set result
} -result {1 1 1 1}
test pathdiscovery-3.1 {end-to-end: repeated -include-paths flags accumulate and equal the single-value form (2026-07-19 failure (b))} -body {
set a [runtests_discovered -include-paths modules/punk/mix/testsuites/scriptwrap/** -include-paths shell/testsuites/binscripts/**]
set b [runtests_discovered -include-paths {modules/punk/mix/testsuites/scriptwrap/** shell/testsuites/binscripts/**}]
test pathdiscovery-3.1 {end-to-end: repeated -include-paths flags accumulate and equal the single-value form} -body {
set a [runtests_discovered -include-paths modules/punk/mix/testsuites/scriptwrap/*** -include-paths shell/testsuites/binscripts/***]
set b [runtests_discovered -include-paths {modules/punk/mix/testsuites/scriptwrap/*** shell/testsuites/binscripts/***}]
list [expr {$a eq $b}]\
[expr {[llength [with_prefix modules/punk/mix/testsuites/scriptwrap $a]] > 0}]\
[expr {[llength [with_prefix shell/testsuites/binscripts $a]] > 0}]
} -result {1 1 1}
test pathdiscovery-3.2 {end-to-end: default discovery covers boundary-direct, deep and this suite's own file} -body {
test pathdiscovery-3.2 {end-to-end: default discovery covers base-direct suites, deep suites and this suite's own file} -body {
set files [runtests_discovered]
set result [list]
foreach path {
@ -216,9 +229,9 @@ namespace eval ::testspace {
set result
} -result {1 1 1}
test pathdiscovery-3.3 {end-to-end: -exclude-paths yields exactly the default list minus the excluded subtree, order preserved} -body {
test pathdiscovery-3.3 {end-to-end: -exclude-paths X/*** yields exactly the default list minus the excluded subtree, order preserved} -body {
set full [runtests_discovered]
set excl [runtests_discovered -exclude-paths modules/punk/mix/testsuites/scriptwrap/**]
set excl [runtests_discovered -exclude-paths modules/punk/mix/testsuites/scriptwrap/***]
set manual [list]
foreach p $full {
if {![string match modules/punk/mix/testsuites/scriptwrap/* $p]} {
@ -228,14 +241,26 @@ namespace eval ::testspace {
list [expr {$excl eq $manual}] [expr {[llength $full] > [llength $excl]}]
} -result {1 1}
test pathdiscovery-3.4 {end-to-end: path patterns compose with an explicit file tail (json report mode)} -body {
runtests_discovered -report json -include-paths {modules/punk/mix/testsuites/scriptwrap/** shell/testsuites/binscripts/** modules/argparsingtest/** shell/testsuites/punkexe/**} multishell.test
test pathdiscovery-3.4 {end-to-end: directory patterns compose with an explicit file tail (json report mode)} -body {
runtests_discovered -report json -include-paths {modules/punk/mix/testsuites/scriptwrap/*** shell/testsuites/binscripts/*** modules/argparsingtest/*** shell/testsuites/punkexe/***} multishell.test
} -result {modules/punk/mix/testsuites/scriptwrap/multishell.test}
test pathdiscovery-3.5 {end-to-end: an exact relative path discovers exactly that file} -body {
runtests_discovered -include-paths runner/testsuites/discovery/pathdiscovery.test
test pathdiscovery-3.5 {end-to-end: single-file targeting = exact directory pattern + file tail} -body {
runtests_discovered -include-paths runner/testsuites/discovery pathdiscovery.test
} -result {runner/testsuites/discovery/pathdiscovery.test}
test pathdiscovery-3.6 {end-to-end: X/** matching a base-direct suite finds nothing and earns the stderr advisory} -body {
set out [runtests_discover_raw -include-paths modules/punk/mix/testsuites/scriptwrap/**]
set files ""
foreach ln [split $out \n] {
set ln [string trimright $ln \r]
if {[string match {RUNTESTS_FILES*} $ln]} {
set files [lrange $ln 1 end]
}
}
list [llength $files] [regexp {matched no test files} $out]
} -result {0 1}
# cleanup fixture
variable fixbase
if {$fixbase ne "" && [file isdirectory $fixbase]} {

108
src/tests/runtests.tcl

@ -62,7 +62,11 @@ proc runtests_json_testdict_array {testdicts} {
foreach testdict $testdicts {
set fields [list]
foreach {key value} $testdict {
if {[string is entier -strict $value]} {
if {[string is entier -strict $value] && $value eq [string trim $value]} {
#string is entier accepts surrounding whitespace even with -strict: a
#value like "0\n" must take the quoted-string path or the raw newline
#lands inside the json line (broke a report when a failed test's
#result_was carried a trailing newline - found 2026-07-20, G-093 work)
lappend fields "[runtests_json_string $key]:$value"
} else {
lappend fields "[runtests_json_string $key]:[runtests_json_string $value]"
@ -536,31 +540,36 @@ punk::args::define {
stderr from <testfile>
"
-include-paths -type list -default {**} -multiple 1 -help\
"List of glob patterns matched against each test file's path relative to the
test base, using forward slashes as separators.
Only test files whose relative path matches at least one pattern are included.
* matches within a single path segment, ? matches a single character and
** spans path segments - so modules/punk/** matches every test file below
modules/punk, including files sitting directly in modules/punk.
An exact relative path is also a valid pattern (single-file targeting):
-include-paths modules/punk/args/testsuites/args/dynamic.test
A bare directory path without a glob tail matches nothing - append /** to
include a subtree.
"List of directory glob patterns selecting which directories under the test
base contribute test files. Patterns are relative to the test base, use
forward slashes, and match the DIRECTORY containing a test file - the
file-name axis belongs to the trailing file-tail globs (default *.test).
Segment forms (punk::path pathglob syntax - deliberately separable):
modules/punk files directly in modules/punk only
modules/punk/* files in folders exactly one below modules/punk
modules/punk/** files in subtrees strictly below modules/punk
(NOT files directly in modules/punk)
modules/punk/*** files in modules/punk and everything below it
Use the X/*** form to run a whole subtree. Single-file targeting combines
a directory pattern with a file tail:
-include-paths modules/punk/args/testsuites/args dynamic.test
The value is a space-separated list of patterns, and the flag may also be
repeated, with all occurrences accumulating:
-include-paths modules/punk/args/** -include-paths modules/punk/ns/**
-include-paths modules/punk/args/*** -include-paths modules/punk/ns/***
is equivalent to
-include-paths {modules/punk/args/** modules/punk/ns/**}
Files matching -exclude-paths are removed from the included set."
-include-paths {modules/punk/args/*** modules/punk/ns/***}
A received pattern that contributes no files earns a stderr advisory (the
common miss: X/** where X/*** was intended). Directories matching
-exclude-paths are removed from the included set."
-exclude-paths -type list -default {} -multiple 1 -help\
"List of glob patterns matched against each test file's path relative to the
test base - same syntax and accumulation semantics as -include-paths.
Test files matching any pattern are excluded; exclusion wins over inclusion
for the same file.
"List of directory glob patterns excluding directories from discovery - same
syntax, relativity and accumulation semantics as -include-paths.
Patterns whose final segment is ** or *** prune the matched subtree; other
matches exclude only the files directly in the matching directory.
Exclusion wins over inclusion for the same file.
Gives broad runs a middle ground between full and targeted - e.g
-exclude-paths shell/** when no built kit is present, or excluding a heavy
suite family unrelated to the change being tested."
-exclude-paths shell/*** when no built kit is present, or excluding a
heavy suite family unrelated to the change being tested."
-discover-only -type boolean -default 0 -help\
"If true, print the discovered test file list and exit without running any
tests. Always emits one machine-readable line 'RUNTESTS_FILES <path>...'
@ -595,15 +604,16 @@ punk::args::define {
(never piped/redirected output) and honours the NO_COLOR environment variable.
Machine-facing output (the RUNTESTS_RESULT line, json reports) is never
coloured."
-serial-paths -type list -default {modules/punk/console/** modules/opunk/console/**} -help\
"Glob patterns (relative to the test base, forward slashes - same form as
-include-paths) for test files that must NOT run concurrently when -jobs
exceeds 1. Matching files run sequentially after the parallel batch
completes. The default covers the console-state suites (they emit to and
query the shared terminal); pass an empty list to parallelise everything.
Suites driven through piped-stdio child processes (shell/testsuites/**,
modules/punk/repl/**) are parallel by default - add them here if they
prove flaky under a real console."
-serial-paths -type list -default {modules/punk/console/*** modules/opunk/console/***} -help\
"Directory glob patterns (relative to the test base, forward slashes - the
same directory-oriented syntax as -include-paths) selecting test files that
must NOT run concurrently when -jobs exceeds 1: a file is serial when the
directory containing it matches any pattern. Matching files run
sequentially after the parallel batch completes. The default covers the
console-state suites (they emit to and query the shared terminal); pass an
empty list to parallelise everything. Suites driven through piped-stdio
child processes (shell/***, modules/punk/repl/***) are parallel by
default - add them here if they prove flaky under a real console."
@values -min 0 -max -1
glob -type string -multiple 1 -optional 1 -help\
"Names or glob patterns of test files to run.
@ -726,14 +736,30 @@ set file_summaries [list]
set all_passes [list]
#tailglobs is a -multiple 1 positional: each glob must be a separate argument, or multiple globs collapse into one
#Discovery walks the whole tree below test_base (tailglobs applied per directory), then
#-include-paths/-exclude-paths filter the tailbase-relative FILE paths (globmatchpath;
#exclusion wins) - see testsupport/discovery.tcl for the contract and the G-093 rationale
#(treefilenames' own -include-paths matches the directory containing files and excludes
#files directly at an X/** boundary, silently missing suites keeping .test files at the
#subtree root).
#Discovery hands -include-paths/-exclude-paths to punk::path::treefilenames unchanged
#(directory-oriented pathglob semantics, identical everywhere - see
#testsupport/discovery.tcl for the contract and the G-093 history; punk::path 0.4.0's
#*** zero-or-more-segments form makes X/*** the whole-subtree spelling).
set testfiles [runtests_discovery::discover_testfiles $test_base $include_paths $exclude_paths $file_globs $exclude_files]
#per-pattern zero-match advisory (G-093): a received include pattern that contributed no
#files is usually the X/** spelling where X/*** was intended (X/** deliberately excludes
#files directly in X). stderr only - results are unaffected.
if {[dict exists $received -include-paths]} {
foreach ip_pat $include_paths {
set ip_hit 0
foreach tf $testfiles {
if {[runtests_discovery::dir_matches_any [list $ip_pat] $tf]} {
set ip_hit 1
break
}
}
if {!$ip_hit} {
puts stderr "runtests: -include-paths pattern '$ip_pat' matched no test files (note: X/** excludes files directly in X - use X/*** for a subtree including its base)"
}
}
}
if {$opt_discover_only} {
#print the discovered relative paths and exit before any run machinery starts
#(before the multi-process workdir and the UDP tee log workers are created; the
@ -948,13 +974,9 @@ if {$jobs_mode} {
set parallel_files [list]
set serial_files [list]
foreach tf $testfiles {
set is_serial 0
foreach pat $serial_patterns {
if {[punk::path::globmatchpath $pat $tf]} {
set is_serial 1
break
}
}
#directory-of-file classification - same directory-oriented pattern reading as
#-include-paths (G-093; previously matched the file path itself)
set is_serial [runtests_discovery::dir_matches_any $serial_patterns $tf]
if {$is_serial} {
lappend serial_files $tf
} else {

4
src/tests/shell/AGENTS.md

@ -27,13 +27,13 @@ Tests for shell-level behavior, command-line execution, and stdin/stdout interac
## Work Guidance
- Use focused harness runs with `-report compact -show-passes 0 -include-paths shell/**` when editing this subtree.
- Use focused harness runs with `-report compact -show-passes 0 -include-paths shell/***` when editing this subtree.
- Include enough failure output to identify the launched command and stream comparison.
- Keep platform-sensitive assumptions visible in the test body.
## Verification
- `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -include-paths shell/**` passes for changes in this subtree.
- `<tcl_interpreter> src/tests/runtests.tcl -report compact -show-passes 0 -include-paths shell/***` passes for changes in this subtree.
## Child DOX Index

106
src/tests/testsupport/discovery.tcl

@ -1,71 +1,55 @@
#testsupport/discovery.tcl - test-file discovery and path-targeting filter for runtests.tcl
#added 2026-07-19 (agent, G-093)
#testsupport/discovery.tcl - test-file discovery for runtests.tcl
#added 2026-07-19, reworked to directory-oriented pass-through 2026-07-20 (agent, G-093)
#
#Discovery contract (the runner's -include-paths/-exclude-paths semantics):
# - the tree below the test base is walked unrestricted (punk::path::treefilenames with its
# default match-everything include, tailglobs applied per directory as before), then the
# tailbase-RELATIVE FILE PATHS are filtered against the include/exclude patterns using
# punk::path::globmatchpath (* within a segment, ? single char, ** spans segments).
# - a file is included when ANY include pattern matches its relative path, then dropped when
# ANY exclude pattern matches it - exclusion wins over inclusion for the same file.
# - matching FILE paths (not the directory containing them) means modules/punk/mix/** includes
# files sitting directly in modules/punk/mix as well as deeper ones, and an exact relative
# path is a valid single-file pattern. A bare directory path without a glob tail matches
# nothing (append /** for a subtree).
#Discovery contract: -include-paths/-exclude-paths patterns are handed to
#punk::path::treefilenames unchanged and carry ITS directory-oriented semantics -
#patterns match the DIRECTORY containing test files (tailbase-relative, forward
#slashes), with the deliberately separable forms:
# X files directly in X only
# X/* files in folders exactly one below X
# X/** files in the subtrees strictly below X (not files directly in X)
# X/*** files in X and everything below it <- the common subtree form
#File-tail globs (the runtests trailing arguments, default *.test) select FILENAMES
#within the contributing directories - the directory and filename axes are independent.
#-exclude-paths uses the same syntax; **|***-tail patterns prune the matched subtree,
#other matches exclude just that folder's files; exclusion wins over inclusion.
#
#Why the filter exists here rather than passing the patterns to treefilenames (G-093 root
#cause, 2026-07-19): treefilenames' own -include-paths matches the DIRECTORY containing
#files, and its documented X/** boundary semantics exclude files directly in X - so suites
#keeping .test files at the subtree root (e.g modules/punk/mix/testsuites/scriptwrap) were
#silently missed by X/** targeting. runtests' contract - like its -serial-paths matching -
#is file-path oriented, so the patterns are applied to the discovered relative paths instead.
#The walk is unpruned, but the default full run already walked the whole tree; the test tree
#is small.
#History (G-093): the 2026-07-19 increment instead filtered tailbase-relative FILE
#paths at the runtests layer, which fixed the X/** boundary miss but gave
#-include-paths a different meaning here than in treefilenames. Reworked at user
#direction 2026-07-20: punk::path 0.4.0 gained *** (zero or more segments) plus
#include/exclude separability fixes, so the runner passes patterns through and the
#pathglob semantics are identical everywhere.
#
#Sourced by runtests.tcl (parent side - discovery happens once, before any child processes)
#and by the runner-targeting regression suite (runner/testsuites/discovery/pathdiscovery.test).
#Caller must have module paths established before sourcing (bootsupport for the runner,
#the generated payload for suite child processes).
#Sourced by runtests.tcl (parent side - discovery happens once, before any child
#processes) and by the runner-targeting regression suite
#(runner/testsuites/discovery/pathdiscovery.test). Callers need module paths
#established before sourcing: bootsupport for the runner - which therefore needs the
#punk::path 0.4.0 snapshot for *** support - and the generated payload for suite
#child processes.
package require punk::path
package require punk::path 0.4.0-
namespace eval ::runtests_discovery {}
proc ::runtests_discovery::filter_paths {relpaths include_patterns exclude_patterns} {
#pure filter over relative file paths - include when any include pattern matches,
#then exclusion wins. A literal ** include pattern matches every path (fast path).
set include_all [expr {"**" in $include_patterns}]
set kept [list]
foreach relpath $relpaths {
set include $include_all
if {!$include} {
foreach pat $include_patterns {
if {[punk::path::globmatchpath $pat $relpath]} {
set include 1
break
}
}
}
if {!$include} {
continue
}
foreach pat $exclude_patterns {
if {[punk::path::globmatchpath $pat $relpath]} {
set include 0
break
}
}
if {$include} {
lappend kept $relpath
}
}
return $kept
proc ::runtests_discovery::discover_testfiles {base include_patterns exclude_patterns tailglobs exclude_files} {
return [punk::path::treefilenames -directory $base -tailbase $base -include-paths $include_patterns -exclude-paths $exclude_patterns -exclude-files $exclude_files {*}$tailglobs]
}
proc ::runtests_discovery::discover_testfiles {base include_patterns exclude_patterns tailglobs exclude_files} {
#walk everything below base (treefilenames' own include filtering deliberately unused -
#see header), then filter the relative paths. Result keeps treefilenames' natural sort
#order, so runner discovery order is unchanged from pre-G-093 default discovery.
set relpaths [punk::path::treefilenames -directory $base -tailbase $base -include-paths {**} -exclude-files $exclude_files {*}$tailglobs]
return [filter_paths $relpaths $include_patterns $exclude_patterns]
proc ::runtests_discovery::dir_matches_any {patterns filepath} {
#directory-of-file matching for post-discovery classification (-serial-paths and the
#per-pattern zero-match advisory): true when the directory containing filepath matches
#any pattern - the same directory-oriented reading the discovery patterns use.
set dir [file dirname $filepath]
if {$dir eq "."} {
#a file directly at the discovery base - its tailbase-relative directory is the
#empty string (matched by e.g bare *, ** and ***)
set dir ""
}
foreach pat $patterns {
if {[punk::path::globmatchpath $pat $dir]} {
return 1
}
}
return 0
}

Loading…
Cancel
Save