Browse Source
New suite src/tests/modules/punk/libunknown/testsuites/shadowing/ (7 tests, all pass) pins the .tm same-version shadowing rules verified experimentally 2026-07-06/07: tcl:™️:add PREPENDS each argument (last arg ends at the head of tcl:™️:list); the head of tcl:™️:list wins exact-version ties (single and separate add calls); a higher version wins from any position (order only arbitrates exact ties); and punk::libunknown preserves all of it (scanner parity). Shipped behaviour depends on these rules - the runtests testinterp tm ordering (bootsupport over vendormodules) and punk_main''s package-mode block precedence that G-033''s proj:/kit-first design builds on - so a Tcl or libunknown change that shifts the tie-break now goes red here instead of silently mis-resolving. Goal G-035 (proposed): characterise mixed .tm / pkgIndex.tcl provision of the same package - same or differing versions, under standard package unknown, punk::libunknown and punk::packagepreference, across scan-trigger orderings, forget/re-require crossing forms, and 8.6/9. Converts the standing informal rule ("avoid mixing provision forms for one package - unexpected behaviour even with libunknown''s improvements") from folklore into either substantiated AGENTS.md guidance with named failure modes, or retirement if mixing proves predictable. Discovery work, so goal-specified rather than written now; extends the shadowing suite when implemented. Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.commaster
4 changed files with 254 additions and 0 deletions
@ -0,0 +1,69 @@
|
||||
# G-035 Characterise mixed .tm / pkgIndex.tcl provision of the same package |
||||
|
||||
Status: proposed |
||||
Scope: src/tests/modules/punk/libunknown/testsuites/ (characterization suite), src/modules/punk/libunknown-0.1.tm and src/modules/punk/packagepreference-999999.0a1.0.tm (as characterised, fixed only if outright bugs surface), src/modules/AGENTS.md + src/lib/AGENTS.md (resulting guidance) |
||||
Acceptance: as in root GOALS.md index (canonical). |
||||
|
||||
## Context |
||||
|
||||
Tcl packages arrive by two registration routes with different machinery: |
||||
|
||||
- **.tm modules**: `tcl::tm` path scan during `package unknown`; registration |
||||
keyed on name-version from the filename; the same-version tie-break rules |
||||
are now pinned by src/tests/modules/punk/libunknown/testsuites/shadowing/ |
||||
(2026-07-07): tcl::tm::add prepends, head of tcl::tm::list wins exact-version |
||||
ties, version beats order, punk::libunknown preserves all of it. |
||||
- **pkgIndex.tcl libraries**: auto_path directory scan (tclPkgUnknown) |
||||
evaluating index scripts; different scan timing, different overwrite |
||||
semantics, `auto_path` ordering rather than tm-list ordering. |
||||
|
||||
When the SAME package is provided by both routes - same version or different |
||||
versions - the interaction is not well characterised. The user's standing |
||||
informal rule (recalled 2026-07-07): **avoid mixing .tm and pkgIndex.tcl |
||||
provision for one package** - unexpected behaviour was observed even after |
||||
punk::libunknown's improvements to the unknown-handler chain. That rule is |
||||
currently folklore: undocumented, unproven, and untested. Meanwhile the punk |
||||
ecosystem genuinely straddles both forms (src/modules .tm trees vs src/lib |
||||
pkgIndex libraries; kits carry both; punk::packagepreference overloads |
||||
`package require` on top of the unknown-handler chain), so accidental mixing |
||||
is a realistic hazard - and same-version-different-content drift (the |
||||
2026-07-06 textblock incident class) would interact with it unpredictably. |
||||
|
||||
## Questions the characterisation must answer |
||||
|
||||
- Same name+version via .tm and via pkgIndex.tcl: which registration wins? |
||||
Is it deterministic, or dependent on which scan ran first (tm scan vs |
||||
auto_path scan can be triggered in either order by unrelated requires)? |
||||
- Different versions across forms: does version selection stay correct |
||||
(including under `package prefer latest` and with alpha/dev versions)? |
||||
Does the losing form's `package ifneeded` registration linger, and can it |
||||
resurface via `package forget` + re-require? |
||||
- How do punk::libunknown and punk::packagepreference each change the |
||||
answers? (libunknown replaces/wraps the unknown chain; packagepreference |
||||
overloads `package require` itself - three layers that can each reorder |
||||
scans.) |
||||
- Are there differences across Tcl 8.6 and 9 (tm.tcl and tclPkgUnknown have |
||||
evolved)? |
||||
|
||||
## Approach |
||||
|
||||
- Extend the shadowing suite's child-interp probe pattern: scratch dirs |
||||
providing the same test package as a .tm and as a pkgIndex.tcl library, all |
||||
combinations of {same version, tm newer, pkgIndex newer} x {standard |
||||
scanner, libunknown, packagepreference} x scan-trigger orderings. |
||||
- Pin observed behaviour; mark surprising-but-tolerable outcomes with |
||||
GAP/known-quirk comments (the fossilmove characterization pattern) rather |
||||
than encoding wishes; anything outright broken is fixed or filed. |
||||
- Convert the folklore into documentation: AGENTS.md guidance in src/modules |
||||
and src/lib stating either the substantiated avoid-mixing rule with its |
||||
named failure modes, or the conditions under which mixing is safe. |
||||
|
||||
## Notes |
||||
|
||||
- Related: the shadowing pin-tests (same suite family - this goal's tests |
||||
extend them), G-033 (visitor-mode path mixing makes cross-form collisions |
||||
more reachable: a kit's internal libs vs a visited project's .tm trees), |
||||
G-026/G-024 (same-version drift provenance), punk::packagepreference 0.1.1 |
||||
(its moduledoc auto-load hook lives in exactly this machinery). |
||||
- Out of scope: redesigning the loading machinery. This goal is |
||||
characterise-document-and-pin; behavioural changes only for outright bugs. |
||||
@ -0,0 +1,178 @@
|
||||
# -*- tcl -*- |
||||
# Pin-tests for Tcl module (.tm) same-version shadowing rules, under both the |
||||
# standard 'package unknown' scanner and punk::libunknown. |
||||
# |
||||
# Rules under test (verified experimentally 2026-07-06 on Tcl 9.0.3): |
||||
# 1. tcl::tm::add PREPENDS each of its arguments in turn - a single call |
||||
# 'tcl::tm::add A B' yields tcl::tm::list beginning {B A ...} (the LAST |
||||
# argument ends up at the head). |
||||
# 2. For the SAME module name-version found under multiple tm paths, the copy |
||||
# under the path nearest the HEAD of tcl::tm::list wins (first-scanned |
||||
# registration is kept, not overwritten). |
||||
# 3. Order only arbitrates exact-version ties: a higher version wins from any |
||||
# position. |
||||
# 4. punk::libunknown preserves all of the above (parity with the standard |
||||
# scanner). |
||||
# |
||||
# Shipped behaviour DEPENDS on these rules - if they change, more than this |
||||
# file breaks: |
||||
# - src/tests/runtests.tcl orders its testinterp tm list so bootsupport wins |
||||
# same-version ties over vendormodules (see its ORDERING NOTE; the |
||||
# stale-textblock incident of 2026-07-06). |
||||
# - punk_main.tcl's package-mode assembly uses block order as the deliberate |
||||
# same-version preference mechanism, and goal G-033's proj:/kit-first design |
||||
# builds on it. |
||||
# |
||||
# Run: tclsh src/tests/runtests.tcl -report compact -show-passes 0 -include-paths modules/punk/libunknown/** shadowing.test |
||||
|
||||
package require tcltest |
||||
package require punk::lib |
||||
|
||||
namespace eval ::testspace { |
||||
namespace import ::tcltest::* |
||||
|
||||
# ------------------------------------------------------------------------- |
||||
# Fixture: two tm dirs each providing shadowtest-1.0 with different impls, |
||||
# dirB additionally provides shadowver-1.0, dirA provides shadowver-1.1 |
||||
# (for the version-beats-order pin). |
||||
# ------------------------------------------------------------------------- |
||||
variable fixture_error "" |
||||
variable dirA "" |
||||
variable dirB "" |
||||
try { |
||||
set base [punk::lib::tempdir_newfolder -prefix pkshadowtest] |
||||
set dirA [file join $base tmA] |
||||
set dirB [file join $base tmB] |
||||
file mkdir $dirA $dirB |
||||
set fd [open [file join $dirA shadowtest-1.0.tm] w] |
||||
puts $fd {package provide shadowtest 1.0; proc ::shadow_whoami {} {return A}} |
||||
close $fd |
||||
set fd [open [file join $dirB shadowtest-1.0.tm] w] |
||||
puts $fd {package provide shadowtest 1.0; proc ::shadow_whoami {} {return B}} |
||||
close $fd |
||||
#version-beats-order fixture: higher version deliberately placed in the |
||||
#tie-LOSING dir for the A-first arrangement |
||||
set fd [open [file join $dirA shadowver-1.1.tm] w] |
||||
puts $fd {package provide shadowver 1.1; proc ::shadowver_whoami {} {return A11}} |
||||
close $fd |
||||
set fd [open [file join $dirB shadowver-1.0.tm] w] |
||||
puts $fd {package provide shadowver 1.0; proc ::shadowver_whoami {} {return B10}} |
||||
close $fd |
||||
variable tempbase $base |
||||
} on error {result} { |
||||
set fixture_error $result |
||||
} |
||||
testConstraint shadowfixture [expr {$fixture_error eq ""}] |
||||
if {$fixture_error ne ""} { |
||||
puts stderr "shadowing.test fixture setup failed (tests will be skipped): $fixture_error" |
||||
} |
||||
|
||||
#punk::libunknown must be resolvable from the testinterp's module paths for |
||||
#the parity tests (bootsupport provides it). |
||||
testConstraint libunknownavailable [expr {![catch {package require punk::libunknown}]}] |
||||
|
||||
# ------------------------------------------------------------------------- |
||||
# Probe: fresh child interp; optionally installs punk::libunknown; sets its |
||||
# tm paths to the scratch dirs (plus the parent's paths so punk modules |
||||
# resolve); triggers a scan; returns dict {head winner verwinner} |
||||
# head - first element of the child's tcl::tm::list (tail component) |
||||
# winner - which impl of shadowtest-1.0 loaded (A|B) |
||||
# verwinner- which impl of shadowver loaded (A11|B10) |
||||
# addmode: single -> one call: tcl::tm::add d1 d2 |
||||
# separate-> tcl::tm::add d1; tcl::tm::add d2 |
||||
# ------------------------------------------------------------------------- |
||||
proc shadow_probe {d1 d2 addmode libunknown} { |
||||
set i [interp create] |
||||
try { |
||||
interp eval $i {tcl::tm::remove {*}[tcl::tm::list]} |
||||
if {$libunknown} { |
||||
#make punk::libunknown loadable, then activate it |
||||
interp eval $i [list tcl::tm::add {*}[tcl::tm::list]] |
||||
interp eval $i {package require punk::libunknown; punk::libunknown::init} |
||||
interp eval $i {tcl::tm::remove {*}[tcl::tm::list]} |
||||
} |
||||
switch -- $addmode { |
||||
single { interp eval $i [list tcl::tm::add $d1 $d2] } |
||||
separate { |
||||
interp eval $i [list tcl::tm::add $d1] |
||||
interp eval $i [list tcl::tm::add $d2] |
||||
} |
||||
default { error "unknown addmode '$addmode'" } |
||||
} |
||||
set head [file tail [lindex [interp eval $i tcl::tm::list] 0]] |
||||
interp eval $i {package require shadowtest} |
||||
set winner [interp eval $i shadow_whoami] |
||||
interp eval $i {package require shadowver} |
||||
set verwinner [interp eval $i shadowver_whoami] |
||||
return [dict create head $head winner $winner verwinner $verwinner] |
||||
} finally { |
||||
interp delete $i |
||||
} |
||||
} |
||||
|
||||
# -- rule 1+2: standard scanner ------------------------------------------------ |
||||
|
||||
test shadow_tm_prepend_and_headwins {single-call add A B: list head is B (prepend rule) and B's impl wins the tie}\ |
||||
-constraints shadowfixture -body { |
||||
variable dirA; variable dirB |
||||
set r [shadow_probe $dirA $dirB single 0] |
||||
list [dict get $r head] [dict get $r winner] |
||||
} -result {tmB B} |
||||
|
||||
test shadow_tm_reversed {single-call add B A: list head is A and A's impl wins the tie}\ |
||||
-constraints shadowfixture -body { |
||||
variable dirA; variable dirB |
||||
set r [shadow_probe $dirB $dirA single 0] |
||||
list [dict get $r head] [dict get $r winner] |
||||
} -result {tmA A} |
||||
|
||||
test shadow_tm_separate_calls {separate add calls: later-added dir is at head and wins the tie}\ |
||||
-constraints shadowfixture -body { |
||||
variable dirA; variable dirB |
||||
set r [shadow_probe $dirA $dirB separate 0] |
||||
list [dict get $r head] [dict get $r winner] |
||||
} -result {tmB B} |
||||
|
||||
# -- rule 3: version beats order ------------------------------------------------ |
||||
|
||||
test shadow_tm_version_beats_order {higher version wins even from the tie-losing position}\ |
||||
-constraints shadowfixture -body { |
||||
variable dirA; variable dirB |
||||
#B is at head (wins ties) but shadowver-1.1 lives in A: version selection |
||||
#must pick 1.1 regardless of path order |
||||
set r [shadow_probe $dirA $dirB single 0] |
||||
dict get $r verwinner |
||||
} -result A11 |
||||
|
||||
# -- rule 4: punk::libunknown parity -------------------------------------------- |
||||
|
||||
test shadow_libunknown_parity {libunknown scanner: same prepend/head-wins outcome as standard}\ |
||||
-constraints {shadowfixture libunknownavailable} -body { |
||||
variable dirA; variable dirB |
||||
set r [shadow_probe $dirA $dirB single 1] |
||||
list [dict get $r head] [dict get $r winner] |
||||
} -result {tmB B} |
||||
|
||||
test shadow_libunknown_parity_reversed {libunknown scanner: reversed order parity}\ |
||||
-constraints {shadowfixture libunknownavailable} -body { |
||||
variable dirA; variable dirB |
||||
set r [shadow_probe $dirB $dirA single 1] |
||||
list [dict get $r head] [dict get $r winner] |
||||
} -result {tmA A} |
||||
|
||||
test shadow_libunknown_version_beats_order {libunknown scanner: version still beats order}\ |
||||
-constraints {shadowfixture libunknownavailable} -body { |
||||
variable dirA; variable dirB |
||||
set r [shadow_probe $dirA $dirB single 1] |
||||
dict get $r verwinner |
||||
} -result A11 |
||||
|
||||
# cleanup fixture |
||||
variable tempbase |
||||
if {[info exists tempbase] && $tempbase ne "" && [file isdirectory $tempbase]} { |
||||
catch {file delete -force $tempbase} |
||||
} |
||||
} |
||||
|
||||
tcltest::cleanupTests |
||||
namespace delete ::testspace |
||||
Loading…
Reference in new issue