diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a957d2..53d1873b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ The latest `## [X.Y.Z]` header must match the `version` field in `punkproject.to Entries are newest-first; one bullet per notable change. See the root `AGENTS.md` "Project Versioning" section for the bump policy. +## [0.44.0] - 2026-08-02 + +- `make.tcl bake` refuses a kit whose source .vfs carries two root-level + suppliers of the startup-script name main.tcl - an actual main.tcl plus a + fauxlink resolving to that name, or several such fauxlinks (G-031 collision + gate). Previously merge_over materialised whichever supplier traversal met + last (its fauxlink branch overwrites with no warning), so the built kit's + boot script depended silently on directory order. Refusal is per-kit under + FAILED KITS, before any build product is written: kits from other .vfs + folders proceed and a previously deployed bin/ is never replaced over + the ambiguity. Folders with NO startup script stay legal (warning only, as + before); nested app/main.tcl links (the undroidwish convention) are not + root suppliers. Verdict predicate: + punkboot::utils::vfs_startup_script_report (0.7.0), fixture-characterized + in src/tests/modules/punkboot/utils/testsuites/utils/startupscript.test; + same guarded-require degradation as the G-125/G-133/G-134 checks when the + bootsupport snapshot is stale. + ## [0.43.0] - 2026-08-02 - Launch package_mode `dev` renamed `minted` (clean break, NO alias): the mode diff --git a/punkproject.toml b/punkproject.toml index d54809e0..5ee272c1 100644 --- a/punkproject.toml +++ b/punkproject.toml @@ -1,6 +1,6 @@ [project] name = "punkshell" -version = "0.43.0" +version = "0.44.0" license = "BSD-2-Clause" url = "https://www.gitea1.intx.com.au/jn/punkshell" #packager: declared identity for published artifacts (declarative, not proof - diff --git a/src/make.tcl b/src/make.tcl index 98eaffcc..ddf63955 100644 --- a/src/make.tcl +++ b/src/make.tcl @@ -4435,6 +4435,20 @@ proc ::punkboot::get_vfs_boot_library_report {vfsfolder} { } return [list 1 [::punkboot::utils::vfs_boot_library_report $vfsfolder]] } +#Availability probe + root startup-script census of a kit SOURCE .vfs folder (G-031 +#startup-script collision gate), consumed by the bake loop's per-kit refusal. Same +#guarded-require treatment as the boot-precondition check above: a stale bootsupport +#snapshot degrades the gate to a NOTE. Returns {available report}. +proc ::punkboot::get_vfs_startup_script_report {vfsfolder} { + set available [expr {\ + ![catch {package require punkboot::utils}]\ + && [llength [info commands ::punkboot::utils::vfs_startup_script_report]]\ + }] + if {!$available} { + return [list 0 [dict create]] + } + return [list 1 [::punkboot::utils::vfs_startup_script_report $vfsfolder]] +} #Availability probe + advisory payload/target binary-arch scan of an assembled kit vfs #(G-133), shared by the bake loop and the 'check' command report. Same guarded-require #treatment as the boot-precondition check above: a stale bootsupport snapshot degrades @@ -8421,6 +8435,8 @@ foreach vfstail $vfs_tails { #(suffixed with .exe for windows-family targets, whether or not mapvfs.config spelled it that way) puts " vfs: $vfstail runtimes to process ([llength $runtimes]): $runtimes" + set startup_collision 0 + set startup_collision_report [dict create] if {[llength $runtimes]} { #only check startup script for .vfs folders that will actually be built against a runtime #(e.g _vfscommon.vfs is a merge overlay with no runtime mapping and legitimately has no main.tcl) @@ -8428,6 +8444,19 @@ foreach vfstail $vfs_tails { if {$startup_warning ne ""} { ::punkboot::print_bake_warnings [list $startup_warning] } + #G-031 startup-script collision gate verdict for this SOURCE .vfs, consumed + #per-kit below (every kit built from a colliding folder is refused; kits from + #other folders proceed). merge_over's fauxlink branch overwrites without + #warning, so a collision lets traversal order silently decide which boot + #script the built kit carries. + lassign [::punkboot::get_vfs_startup_script_report $sourcefolder/vfs/$vfstail] startup_check_available startup_collision_report + if {!$startup_check_available} { + puts stderr "NOTE: startup-script collision check unavailable (punkboot::utils vfs_startup_script_report not loadable from bootsupport) - continuing without it" + } elseif {[dict get $startup_collision_report status] eq "unchecked"} { + puts stderr "NOTE: startup-script collision check could not run ([dict get $startup_collision_report reason]) - continuing without it" + } elseif {![dict get $startup_collision_report ok]} { + set startup_collision 1 + } } #todo - non kit based - zipkit? # $runtimes may now include a dash entry "-" (from mapvfs.config file) @@ -8544,6 +8573,18 @@ foreach vfstail $vfs_tails { lappend skipped_kits [list kit $targetkit reason "deployed executable is running this build"] continue } + #G-031 startup-script collision gate: two root-level suppliers of the name + #main.tcl in the source .vfs mean the baked boot script depends on merge + #traversal order. Refuse the kit BEFORE any build product is written - as + #with the G-125 gate, a previously deployed bin/ must never be + #replaced over an ambiguity; kits from other .vfs folders proceed. + if {$startup_collision} { + set failmsg "startup-script collision in src/vfs/$vfstail - [dict get $startup_collision_report reason]. NOT BUILT and NOT DEPLOYED: any previously deployed $targetkit is untouched" + puts stderr "startup-script collision gate FAILED for kit $targetkit - refusing to bake an ambiguous boot script" + puts stderr " [dict get $startup_collision_report reason]" + lappend failed_kits [list kit $targetkit reason $failmsg] + continue + } # -- ---------- set vfs_installer [punkcheck::installtrack new $installername $basedir/.punkcheck] $vfs_installer set_source_target $sourcefolder $bakefolder diff --git a/src/modules/punkboot/utils-999999.0a1.0.tm b/src/modules/punkboot/utils-999999.0a1.0.tm index 2a67b6f7..49ab7b46 100644 --- a/src/modules/punkboot/utils-999999.0a1.0.tm +++ b/src/modules/punkboot/utils-999999.0a1.0.tm @@ -23,7 +23,7 @@ package require Tcl 8.6- # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ namespace eval punkboot::utils { variable version 0.1.0 - namespace export binary_arch_classify kit_offsetstyle_report parse_punkproject_version read_punkproject_version read_changelog_latest_version vcs_dirty_warnings vfs_binary_arch_report vfs_boot_library_report + namespace export binary_arch_classify kit_offsetstyle_report parse_punkproject_version read_punkproject_version read_changelog_latest_version vcs_dirty_warnings vfs_binary_arch_report vfs_boot_library_report vfs_startup_script_report namespace eval argdoc { @@ -336,6 +336,92 @@ namespace eval punkboot::utils { return $report } + + namespace eval argdoc { + lappend PUNKARGS [list { + @id -id ::punkboot::utils::vfs_startup_script_report + @cmd -name "::punkboot::utils::vfs_startup_script_report"\ + -summary\ + "Report what supplies a kit source .vfs folder's root startup script"\ + -help\ + "Structural census of the root-level startup script of a kit SOURCE + .vfs folder (pre-merge): an actual main.tcl file, root fauxlinks + whose resolved nominal name is main.tcl (target existence is not + examined - presence of the link is what matters), or neither. + Reads directory names only - nothing is executed and no fauxlink + target is opened. + + ok is 0 only for the G-031 startup-script collision: more than one + root-level supplier of the name main.tcl (an actual main.tcl file + plus one or more fauxlinks resolving to that name, or several such + fauxlinks). A merge materialises whichever supplier it meets last, + so a collision lets traversal order silently decide the kit's boot + script - make.tcl's bake refuses such a kit rather than build an + ambiguous artifact. A folder with NO startup script is ok here + (legal bare kit - the bake's separate missing-startup warning owns + that case), as is a nested fauxlink such as app/main.tcl (the + undroidwish convention, G-129) - root level only. + + Returned dict keys: + ok 0 only on collision (or unreadable folder) + status one of: main fauxlink none collision unchecked + mainfile main.tcl when the actual file is present, else \"\" + mainlinks root fauxlink filenames resolving to main.tcl + unresolvable root fauxlink filenames fauxlink::resolve rejects + reason why ok is 0 (or unchecked); empty string when clean" + @leaders + vfsfolder -type string -optional 0 -help\ + "Path of the kit source .vfs folder to inspect" + }] + } + proc vfs_startup_script_report {vfsfolder} { + set report [dict create ok 1 status none mainfile "" mainlinks {} unresolvable {} reason ""] + if {![file isdirectory $vfsfolder]} { + dict set report ok 0 + dict set report reason "no such directory: $vfsfolder" + return $report + } + if {[catch {package require fauxlink} errM]} { + #cannot examine links - report unchecked rather than guessing (callers + #degrade to a notice; the gate must not refuse kits it could not check) + dict set report status unchecked + dict set report reason "cannot check: fauxlink package unavailable ($errM)" + return $report + } + if {[file isfile [file join $vfsfolder main.tcl]]} { + dict set report mainfile main.tcl + } + set mainlinks [list] + set unresolvable [list] + foreach link [lsort [glob -nocomplain -dir $vfsfolder -types f *.fxlnk *.fauxlink]] { + if {[catch {fauxlink::resolve $link} linkinfo]} { + lappend unresolvable [file tail $link] + continue + } + #resolved nominal name - covers empty-nominalname links whose effective + #name is the target's tail (filename-prefix parsing would miss them) + if {[dict get $linkinfo name] eq "main.tcl"} { + lappend mainlinks [file tail $link] + } + } + dict set report mainlinks $mainlinks + dict set report unresolvable $unresolvable + set suppliers $mainlinks + if {[dict get $report mainfile] ne ""} { + set suppliers [linsert $mainlinks 0 main.tcl] + } + if {[llength $suppliers] > 1} { + dict set report ok 0 + dict set report status collision + dict set report reason "[llength $suppliers] root-level suppliers of the startup-script name main.tcl coexist in [file tail $vfsfolder]: [join $suppliers { AND }] - a merge materialises whichever it meets last, so traversal order silently decides the kit's boot script. Keep exactly one (the fauxlink into _config/ is the punkshell convention)" + } elseif {[dict get $report mainfile] ne ""} { + dict set report status main + } elseif {[llength $mainlinks]} { + dict set report status fauxlink + } + return $report + } + #G-133 binary-arch scan support. Recognition data is deliberately plain namespace #variables so extending it (a new vendor spelling, a new cpu token) is a visible #one-line edit rather than a code change. diff --git a/src/modules/punkboot/utils-buildversion.txt b/src/modules/punkboot/utils-buildversion.txt index 92f6df4a..d9058b3f 100644 --- a/src/modules/punkboot/utils-buildversion.txt +++ b/src/modules/punkboot/utils-buildversion.txt @@ -1,6 +1,7 @@ -0.6.2 +0.7.0 #First line must be a semantic version number #all other lines are ignored. +#0.7.0 - added vfs_startup_script_report (root startup-script census of a kit SOURCE .vfs folder: actual main.tcl vs fauxlink-resolved main.tcl suppliers, collision verdict; backs make.tcl's G-031 startup-script collision gate) #0.6.2 - vcs_dirty_warnings wording: "artifacts produced from a dirty tree" (G-155 stage vocabulary; was "built") #0.6.1 - doc-only: kit_offsetstyle_report argdoc names the recapped tag BAKE-WARNING (G-155 stage vocabulary; tag renamed from BUILD-WARNING in make.tcl) #0.6.0 - added kit_offsetstyle_report (advisory zip offset-style probe of an assembled kit image via punk::zip::archive_info; backs make.tcl's G-134 archive-relative output pin) diff --git a/src/tests/modules/punkboot/utils/testsuites/utils/startupscript.test b/src/tests/modules/punkboot/utils/testsuites/utils/startupscript.test new file mode 100644 index 00000000..8c596735 --- /dev/null +++ b/src/tests/modules/punkboot/utils/testsuites/utils/startupscript.test @@ -0,0 +1,187 @@ +# -*- tcl -*- +# Tests for punkboot::utils::vfs_startup_script_report - the structural startup-script +# census behind make.tcl's G-031 startup-script collision gate: +# - the three clean shapes a kit SOURCE .vfs folder may have (actual root main.tcl, +# root fauxlink resolving to main.tcl, none at all - the last is legal and owned by +# the bake's separate missing-startup warning) +# - the collision verdict: more than one root-level supplier of the name main.tcl +# (actual file + fauxlink, or several fauxlinks) - the case merge_over materialises +# in traversal order with no overwrite warning from its fauxlink branch +# - resolved-NAME detection (an empty-nominalname link whose target tail is main.tcl +# collides; filename-prefix parsing would miss it), nested app/main.tcl exemption +# (G-129 undroidwish convention), unresolvable-link reporting, and the real source +# .vfs folders of this checkout (which must all be collision-free) +# Directory fixtures only - the check reads names, never opens targets, executes nothing. +# Run: tclsh src/tests/runtests.tcl -report compact -show-passes 0 -include-paths modules/punkboot/utils/*** startupscript.test + +package require tcltest +package require punkboot::utils + +#added 2026-08-02 (agent, G-031) + +namespace eval ::testspace { + namespace import ::tcltest::* + + variable BASE [makeDirectory g031_startupscript] + + #/src/tests/modules/punkboot/utils/testsuites/utils -> 7 levels up + variable projectroot [file normalize [file join [file dirname [info script]] .. .. .. .. .. .. ..]] + variable srcvfsfolder [file join $projectroot src vfs] + + proc mkfile {path} { + file mkdir [file dirname $path] + set fd [open $path w] + puts -nonewline $fd "#fixture\n" + close $fd + } + #build a fresh fixture tree under BASE and return its path + proc mktree {name args} { + variable BASE + set root [file join $BASE $name] + file delete -force $root + file mkdir $root + foreach rel $args { + if {[string index $rel end] eq "/"} { + file mkdir [file join $root [string trimright $rel /]] + } else { + mkfile [file join $root $rel] + } + } + return $root + } + proc report {root} { + return [punkboot::utils::vfs_startup_script_report $root] + } + + #the layout/punkshell convention form: name # target # tagset # (empty comment) + variable FXMAIN {main.tcl#..+_config+kit_main.tcl#@punk%3a%3aboot,merge_over#.fxlnk} + #minimal form: name # target + variable FXMAIN2 {main.tcl#..+_config+other_main.tcl.fxlnk} + #empty nominal name - effective name is the decoded target's tail (main.tcl) + variable FXNONAME {#..+_config+main.tcl.fxlnk} + #resolves to a name that is not main.tcl + variable FXOTHER {other.tcl#..+_config+kit_main.tcl.fxlnk} + + variable common { + set result [list] + } + + # -- --- --- the clean shapes --- --- -- + + test startup_main_only {an actual root main.tcl alone is clean - status main}\ + -setup $common -body { + set r [report [mktree mainonly main.tcl lib/ modules/]] + lappend result [dict get $r ok] [dict get $r status] [dict get $r mainfile] [dict get $r mainlinks] + }\ + -result {1 main main.tcl {}} + + test startup_fauxlink_only {a root fauxlink resolving to main.tcl alone is clean - status fauxlink}\ + -setup $common -body { + variable FXMAIN + set r [report [mktree linkonly $FXMAIN modules/]] + lappend result [dict get $r ok] [dict get $r status] [dict get $r mainfile] [dict get $r mainlinks] + }\ + -result [list 1 fauxlink {} [list $FXMAIN]] + + test startup_none {no startup script at all is NOT a collision - the missing case belongs to the bake warning, not this gate}\ + -setup $common -body { + set r [report [mktree bare lib/ modules/]] + lappend result [dict get $r ok] [dict get $r status] [expr {[dict get $r reason] eq ""}] + }\ + -result {1 none 1} + + # -- --- --- the collisions --- --- -- + + test startup_collision_main_plus_fauxlink {an actual main.tcl AND a fauxlink resolving to main.tcl collide - both named in the reason}\ + -setup $common -body { + variable FXMAIN + set r [report [mktree collide1 main.tcl $FXMAIN]] + lappend result [dict get $r ok] [dict get $r status] + lappend result [string match "*main.tcl AND *$FXMAIN*" [dict get $r reason]] + lappend result [string match "*traversal order*" [dict get $r reason]] + }\ + -result {0 collision 1 1} + + test startup_collision_two_fauxlinks {two root fauxlinks both resolving to main.tcl collide even with no actual file}\ + -setup $common -body { + variable FXMAIN + variable FXMAIN2 + set r [report [mktree collide2 $FXMAIN $FXMAIN2]] + lappend result [dict get $r ok] [dict get $r status] [llength [dict get $r mainlinks]] + }\ + -result {0 collision 2} + + test startup_collision_empty_nominalname {an empty-nominalname link whose target tail is main.tcl collides - detection is by RESOLVED name}\ + -setup $common -body { + variable FXNONAME + set r [report [mktree collide3 main.tcl $FXNONAME]] + lappend result [dict get $r ok] [dict get $r status] [dict get $r mainlinks] + }\ + -result [list 0 collision [list $FXNONAME]] + + # -- --- --- what must NOT collide --- --- -- + + test startup_other_fauxlink_no_collision {a fauxlink resolving to a different name does not collide with main.tcl}\ + -setup $common -body { + variable FXOTHER + set r [report [mktree othername main.tcl $FXOTHER]] + lappend result [dict get $r ok] [dict get $r status] [dict get $r mainlinks] + }\ + -result {1 main {}} + + test startup_nested_fauxlink_exempt {a nested app/main.tcl fauxlink (undroidwish convention, G-129) is not a root supplier}\ + -setup $common -body { + variable FXMAIN + set r [report [mktree nested main.tcl app/$FXMAIN]] + lappend result [dict get $r ok] [dict get $r status] [dict get $r mainlinks] + }\ + -result {1 main {}} + + test startup_unresolvable_listed {a structurally invalid fauxlink is reported unresolvable and does not affect the verdict}\ + -setup $common -body { + set r [report [mktree badlink main.tcl notalink.fxlnk]] + lappend result [dict get $r ok] [dict get $r status] [dict get $r unresolvable] + }\ + -result {1 main notalink.fxlnk} + + # -- --- --- robustness --- --- -- + + test startup_missing_directory {a nonexistent folder is refused by name rather than erroring}\ + -setup $common -body { + variable BASE + set r [report [file join $BASE no_such_vfs]] + lappend result [dict get $r ok] [string match "no such directory:*" [dict get $r reason]] + }\ + -result {0 1} + + test startup_does_not_execute_or_write {the check writes nothing into the tree it inspects}\ + -setup $common -body { + variable FXMAIN + set root [mktree readonlycheck $FXMAIN modules/] + set before [lsort [glob -nocomplain -directory $root -tails *]] + report $root + set after [lsort [glob -nocomplain -directory $root -tails *]] + lappend result [expr {$before eq $after}] + }\ + -result {1} + + # -- --- --- the trees the gate will actually see --- --- -- + + test startup_real_source_vfs_folders {every kit source .vfs folder in this checkout is collision-free - the gate must not refuse kits that bake today}\ + -setup $common -body { + variable srcvfsfolder + set failures [list] + set checked 0 + foreach d [lsort [glob -nocomplain -type d -directory $srcvfsfolder *.vfs]] { + incr checked + set r [report $d] + if {![dict get $r ok]} { + lappend failures [list [file tail $d] [dict get $r reason]] + } + } + lappend result [expr {$checked > 0}] $failures + }\ + -result {1 {}} + + cleanupTests +}