You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

405 lines
13 KiB

#runtests_parity.tcl - compare two 'src/tests/runtests.tcl -report json' outputs for result parity.
#(agent-authored developer utility, 2026-07-18 - created for the -singleproc 0 multi-process
# runner completion: verifies per-file result parity between runner modes/interpreters/contexts)
#
#Usage:
# tclsh scriptlib/developer/runtests_parity.tcl <a.json> <b.json>
# tclsh scriptlib/developer/runtests_parity.tcl -names <a.txt> <b.txt>
#
#Default (json) mode: each input file is the captured stdout of a runtests.tcl run with
#-report json (or markdown+json): the last line starting with {"runner" is taken as the
#report, so preamble lines (argv echo, package-load warnings) and fenced markdown context
#are tolerated.
#
#Compared (deterministic result identity):
# - top-level status and total/passed/skipped/failed tallies
# - the set of test file paths
# - per file: status, total/passed/skipped/failed, failure identities (name+status),
# skip identities (name+reason), warning codes
#Ignored (expected to vary between runs/modes): microseconds, the slowest list,
#observed_* event counts.
#
#-names mode (added 2026-07-19 for G-092 test-file splits): each input is the captured
#stdout of a runtests.tcl run with '-report markdown -show-passes 1'. Every
#'- testname: <name>' line (pass, failure and constraint-skip sections all use this form)
#contributes to a MULTISET of test names with file attribution deliberately ignored, so
#moving tests verbatim between files compares identical while any lost, duplicated or
#renamed test is reported. NAMES: identical -> exit 0, differences -> exit 1.
#
#Exit codes: 0 = parity, 1 = differences found, 2 = usage or parse error.
#No package dependencies (runs under any tclsh 8.6+); contains a minimal JSON parser
#sufficient for the runner's machine-generated report format.
namespace eval jsonlite {
variable str ""
variable pos 0
variable len 0
proc parse {text} {
variable str $text
variable pos 0
variable len [string length $text]
return [parse_value]
}
proc fail {msg} {
variable pos
return -code error "jsonlite parse error at offset $pos: $msg"
}
proc skip_ws {} {
variable str
variable pos
variable len
while {$pos < $len && [string index $str $pos] in [list " " \t \n \r]} {
incr pos
}
}
proc parse_value {} {
variable str
variable pos
variable len
skip_ws
if {$pos >= $len} {
fail "unexpected end of input"
}
switch -- [string index $str $pos] {
\{ {return [parse_object]}
\[ {return [parse_array]}
\" {return [parse_string]}
t {
if {[string range $str $pos $pos+3] ne "true"} {fail "bad literal"}
incr pos 4
return 1
}
f {
if {[string range $str $pos $pos+4] ne "false"} {fail "bad literal"}
incr pos 5
return 0
}
n {
if {[string range $str $pos $pos+3] ne "null"} {fail "bad literal"}
incr pos 4
return ""
}
default {return [parse_number]}
}
}
proc parse_object {} {
variable str
variable pos
incr pos ;#consume open brace
set d [dict create]
skip_ws
if {[string index $str $pos] eq "\}"} {
incr pos
return $d
}
while 1 {
skip_ws
if {[string index $str $pos] ne "\""} {fail "expected object key"}
set k [parse_string]
skip_ws
if {[string index $str $pos] ne ":"} {fail "expected : after object key"}
incr pos
dict set d $k [parse_value]
skip_ws
switch -- [string index $str $pos] {
, {incr pos}
\} {incr pos; return $d}
default {fail "expected , or \} in object"}
}
}
}
proc parse_array {} {
variable str
variable pos
incr pos ;#consume open bracket
set out [list]
skip_ws
if {[string index $str $pos] eq "\]"} {
incr pos
return $out
}
while 1 {
lappend out [parse_value]
skip_ws
switch -- [string index $str $pos] {
, {incr pos}
\] {incr pos; return $out}
default {fail "expected , or \] in array"}
}
}
}
proc parse_string {} {
variable str
variable pos
variable len
incr pos ;#consume opening quote
set out ""
while {$pos < $len} {
set q [string first "\"" $str $pos]
set b [string first "\\" $str $pos]
if {$q < 0} {
fail "unterminated string"
}
if {$b < 0 || $q < $b} {
append out [string range $str $pos [expr {$q - 1}]]
set pos [expr {$q + 1}]
return $out
}
append out [string range $str $pos [expr {$b - 1}]]
set pos [expr {$b + 1}]
set e [string index $str $pos]
switch -- $e {
\" {append out \"}
\\ {append out \\}
/ {append out /}
b {append out \b}
f {append out \f}
n {append out \n}
r {append out \r}
t {append out \t}
u {
set hex [string range $str $pos+1 $pos+4]
if {[string length $hex] != 4 || ![string is xdigit -strict $hex]} {
fail "bad unicode escape"
}
scan $hex %x cp
append out [format %c $cp]
incr pos 4
}
default {fail "bad escape sequence \\$e"}
}
incr pos
}
fail "unterminated string"
}
proc parse_number {} {
variable str
variable pos
variable len
set start $pos
while {$pos < $len && [string index $str $pos] in [list - + . e E 0 1 2 3 4 5 6 7 8 9]} {
incr pos
}
set numtext [string range $str $start [expr {$pos - 1}]]
if {$numtext eq "" || ![string is double -strict $numtext]} {
fail "bad number '$numtext'"
}
return $numtext
}
}
proc load_report {filename} {
if {![file exists $filename]} {
puts stderr "runtests_parity: file not found: $filename"
exit 2
}
set fd [open $filename r]
set data [read $fd]
close $fd
#the report is the last line containing the open-brace+"runner": marker. The marker may
#not be at line start: the runner's punk ANSI output stack can emit an SGR reset sequence
#on the same line immediately before the JSON, and other preamble/noise lines may precede it.
set marker "\{\"runner\":"
set report_line ""
foreach ln [split $data \n] {
set idx [string first $marker $ln]
if {$idx >= 0} {
set report_line [string trimright [string range $ln $idx end] \r]
}
}
if {$report_line eq ""} {
puts stderr "runtests_parity: no runtests JSON report line (containing \{\"runner\":) found in $filename"
exit 2
}
if {[catch {jsonlite::parse $report_line} report]} {
puts stderr "runtests_parity: could not parse JSON report in $filename: $report"
exit 2
}
return $report
}
proc filemap {report} {
set m [dict create]
foreach tf [dict get $report testfiles] {
dict set m [dict get $tf path] $tf
}
return $m
}
proc failure_sigs {tf} {
set sigs [list]
foreach f [dict get $tf failures] {
lappend sigs "[dict get $f name] status=[dict get $f status]"
}
return [lsort $sigs]
}
proc skip_sigs {tf} {
set sigs [list]
foreach s [dict get $tf skips] {
lappend sigs "[dict get $s name] reason=[dict get $s reason]"
}
return [lsort $sigs]
}
proc warning_sigs {tf} {
set sigs [list]
foreach w [dict get $tf warnings] {
lappend sigs [dict get $w code]
}
return [lsort $sigs]
}
proc list_diff_lines {label lista listb} {
set lines [list]
foreach item $lista {
if {$item ni $listb} {
lappend lines " $label only in A: $item"
}
}
foreach item $listb {
if {$item ni $lista} {
lappend lines " $label only in B: $item"
}
}
return $lines
}
proc load_names {filename} {
if {![file exists $filename]} {
puts stderr "runtests_parity: file not found: $filename"
exit 2
}
set fd [open $filename r]
set data [read $fd]
close $fd
set names [list]
foreach ln [split $data \n] {
if {[regexp {^- testname: (.*?)\s*$} $ln -> nm]} {
if {$nm ne ""} {
lappend names $nm
}
}
}
if {![llength $names]} {
puts stderr "runtests_parity: no '- testname:' lines found in $filename (expected captured stdout of: runtests.tcl -report markdown -show-passes 1)"
exit 2
}
return $names
}
proc names_multiset {names} {
set counts [dict create]
foreach n $names {
dict incr counts $n
}
return $counts
}
if {[lindex $argv 0] eq "-names"} {
lassign [lrange $argv 1 end] filea fileb
if {$filea eq "" || $fileb eq "" || [llength $argv] != 3} {
puts stderr "usage: tclsh runtests_parity.tcl -names <a.txt> <b.txt>"
puts stderr " where each file is captured stdout of: <tclsh> src/tests/runtests.tcl -report markdown -show-passes 1 ..."
exit 2
}
set namesa [load_names $filea]
set namesb [load_names $fileb]
set counta [names_multiset $namesa]
set countb [names_multiset $namesb]
puts "runtests test-name comparison (multiset, file attribution ignored)"
puts "A: $filea ([llength $namesa] name occurrences, [dict size $counta] distinct)"
puts "B: $fileb ([llength $namesb] name occurrences, [dict size $countb] distinct)"
set diffs [list]
foreach nm [lsort [dict keys $counta]] {
set ca [dict get $counta $nm]
set cb [expr {[dict exists $countb $nm] ? [dict get $countb $nm] : 0}]
if {$ca != $cb} {
lappend diffs "$nm: A=$ca B=$cb"
}
}
foreach nm [lsort [dict keys $countb]] {
if {![dict exists $counta $nm]} {
lappend diffs "$nm: A=0 B=[dict get $countb $nm]"
}
}
if {[llength $diffs] == 0} {
puts "NAMES: identical ([llength $namesa] test name occurrences)"
exit 0
}
foreach d $diffs {
puts "- $d"
}
puts "NAMES: DIFFER ([llength $diffs] names with differing counts)"
exit 1
}
lassign $argv filea fileb
if {$filea eq "" || $fileb eq "" || [llength $argv] != 2} {
puts stderr "usage: tclsh runtests_parity.tcl <a.json> <b.json>"
puts stderr " tclsh runtests_parity.tcl -names <a.txt> <b.txt>"
puts stderr " where each json file is captured stdout of: <tclsh> src/tests/runtests.tcl -report json ..."
exit 2
}
set ra [load_report $filea]
set rb [load_report $fileb]
set diffs [list]
foreach k {status total passed skipped failed} {
set va [dict get $ra $k]
set vb [dict get $rb $k]
if {$va ne $vb} {
lappend diffs "top-level $k: A=$va B=$vb"
}
}
set ma [filemap $ra]
set mb [filemap $rb]
foreach path [dict keys $ma] {
if {![dict exists $mb $path]} {
lappend diffs "file only in A: $path"
}
}
foreach path [dict keys $mb] {
if {![dict exists $ma $path]} {
lappend diffs "file only in B: $path"
}
}
foreach path [dict keys $ma] {
if {![dict exists $mb $path]} {
continue
}
set tfa [dict get $ma $path]
set tfb [dict get $mb $path]
set flines [list]
foreach k {status total passed skipped failed} {
set va [dict get $tfa $k]
set vb [dict get $tfb $k]
if {$va ne $vb} {
lappend flines " $k: A=$va B=$vb"
}
}
lappend flines {*}[list_diff_lines failure [failure_sigs $tfa] [failure_sigs $tfb]]
lappend flines {*}[list_diff_lines skip [skip_sigs $tfa] [skip_sigs $tfb]]
lappend flines {*}[list_diff_lines warning [warning_sigs $tfa] [warning_sigs $tfb]]
if {[llength $flines]} {
lappend diffs "file $path:"
lappend diffs {*}$flines
}
}
puts "runtests parity comparison"
puts "A: $filea"
puts "B: $fileb"
if {[llength $diffs] == 0} {
puts "PARITY: ok (files=[llength [dict keys $ma]] total=[dict get $ra total] passed=[dict get $ra passed] skipped=[dict get $ra skipped] failed=[dict get $ra failed])"
exit 0
}
foreach d $diffs {
puts "- $d"
}
puts "PARITY: DIFFERS ([llength $diffs] difference lines)"
exit 1