aboutsummaryrefslogtreecommitdiff
path: root/scripts/lib
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/lib')
-rw-r--r--scripts/lib/common.sh105
-rw-r--r--scripts/lib/coverage-report.awk84
-rw-r--r--scripts/lib/render-coverage-index.awk48
-rw-r--r--scripts/lib/render-hotspots.awk56
-rw-r--r--scripts/lib/render-phases.awk41
-rw-r--r--scripts/lib/render-timing.awk41
-rw-r--r--scripts/lib/timing-extract.awk32
-rw-r--r--scripts/lib/trace-extract.awk71
-rw-r--r--scripts/lib/trace-init.sh42
-rw-r--r--scripts/lib/trace-phase-extract.awk75
10 files changed, 595 insertions, 0 deletions
diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh
new file mode 100644
index 0000000..0fdf98a
--- /dev/null
+++ b/scripts/lib/common.sh
@@ -0,0 +1,105 @@
+# scripts/lib/common.sh
+# Shared helpers for the trbldoc report scripts.
+#
+# POSIX sh compatible. Verified to work under mingw bash, WSL bash and
+# Alpine/busybox ash. Sourced by the entrypoint scripts; do not execute
+# directly.
+
+# Resolve the repository root from the location of the scripts/ directory.
+# Sets: REPO_ROOT, SCRIPTS_DIR, LIB_DIR
+tb_resolve_paths() {
+ # $1: path to the calling script ($0)
+ script_path="$1"
+ # Directory containing the calling script (scripts/).
+ SCRIPTS_DIR="$(CDPATH= cd -- "$(dirname -- "$script_path")" && pwd)"
+ LIB_DIR="$SCRIPTS_DIR/lib"
+ REPO_ROOT="$(CDPATH= cd -- "$SCRIPTS_DIR/.." && pwd)"
+ export REPO_ROOT SCRIPTS_DIR LIB_DIR
+}
+
+# Print an ISO-8601 UTC timestamp suitable for directory names.
+# Falls back to epoch seconds when the platform date(1) cannot format UTC.
+tb_timestamp() {
+ if date -u +%Y%m%dT%H%M%SZ 2>/dev/null; then
+ return 0
+ fi
+ printf '%s\n' "$(date +%s)"
+}
+
+# Emit an informational message to stderr.
+tb_info() {
+ printf '[trbldoc-report] %s\n' "$*" >&2
+}
+
+# Emit an error message to stderr and return non-zero.
+tb_die() {
+ printf '[trbldoc-report] ERROR: %s\n' "$*" >&2
+ exit 1
+}
+
+# Verify a command exists on PATH, otherwise abort.
+tb_require() {
+ command -v "$1" >/dev/null 2>&1 || tb_die "required tool not found on PATH: $1"
+}
+
+# Resolve the bats suite directories for a target keyword.
+# $1: all|unit|integration ; echoes space-separated dirs relative to REPO_ROOT.
+tb_suite_dirs() {
+ case "$1" in
+ unit) printf '%s\n' "$REPO_ROOT/tests/unit" ;;
+ integration) printf '%s\n' "$REPO_ROOT/tests/integration" ;;
+ all) printf '%s\n' "$REPO_ROOT/tests/unit $REPO_ROOT/tests/integration" ;;
+ *) tb_die "unknown suite '$1' (expected: all|unit|integration)" ;;
+ esac
+}
+
+# Resolve bats suite directories relative to repository root.
+# $1: all|unit|integration ; echoes a space-separated relative path list.
+tb_suite_dirs_rel() {
+ case "$1" in
+ unit) printf '%s\n' "tests/unit" ;;
+ integration) printf '%s\n' "tests/integration" ;;
+ all) printf '%s\n' "tests/unit tests/integration" ;;
+ *) tb_die "unknown suite '$1' (expected: all|unit|integration)" ;;
+ esac
+}
+
+# Convert a POSIX-style MSYS path (/d/foo/bar) to a WSL Linux path
+# (/mnt/d/foo/bar). Other paths are returned unchanged.
+tb_to_wsl_path() {
+ p="$1"
+ case "$p" in
+ [A-Za-z]:\\*|[A-Za-z]:/*)
+ d="$(printf '%s' "$p" | cut -c1 | tr 'A-Z' 'a-z')"
+ rest="$(printf '%s' "$p" | cut -c3- | sed 's#\\\\#/#g')"
+ rest="$(printf '%s' "$rest" | sed 's#^/*##')"
+ printf '/mnt/%s/%s\n' "$d" "$rest"
+ ;;
+ /[A-Za-z]/*)
+ d="$(printf '%s' "$p" | cut -c2 | tr 'A-Z' 'a-z')"
+ rest="$(printf '%s' "$p" | cut -c4-)"
+ printf '/mnt/%s/%s\n' "$d" "$rest"
+ ;;
+ *)
+ printf '%s\n' "$p"
+ ;;
+ esac
+}
+
+# Returns success if the local `bats` command appears usable for this repo.
+# Some mixed Windows/MSYS setups expose a broken bats wrapper that exits 0
+# without running tests or producing output; this guard catches that case.
+tb_bats_usable() {
+ suite="${1:-integration}"
+ dirs="$(tb_suite_dirs "$suite")"
+ # shellcheck disable=SC2086
+ count="$(bats --count $dirs 2>/dev/null | tr -d '[:space:]')"
+ case "$count" in
+ ''|*[!0-9]*)
+ return 1
+ ;;
+ *)
+ return 0
+ ;;
+ esac
+}
diff --git a/scripts/lib/coverage-report.awk b/scripts/lib/coverage-report.awk
new file mode 100644
index 0000000..982eb45
--- /dev/null
+++ b/scripts/lib/coverage-report.awk
@@ -0,0 +1,84 @@
+# scripts/lib/coverage-report.awk
+# Produce line-coverage reports for shell sources from an executed-line set.
+#
+# Inputs:
+# -v COVERED=<file> TSV of "<base>\t<line>" executed lines (sorted -u).
+# -v HTMLDIR=<dir> directory to write per-file <base>.html reports into.
+# ARGV the source files to report on (absolute paths).
+#
+# Output (stdout): one CSV row per source file:
+# file,total_executable,covered,percent
+#
+# "Executable" lines are determined heuristically: non-blank, non-comment
+# lines that are not pure structural tokens (then/do/done/else/fi/esac/{/}).
+# Heredoc/multiline string bodies are approximated and may read as missed;
+# see docs/coverage.md for the documented limitations.
+
+function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
+
+function is_exec(line, t) {
+ t = trim(line)
+ if (t == "") return 0
+ if (t ~ /^#/) return 0
+ if (t ~ /^(then|do|done|else|fi|esac)([ \t].*)?$/) return 0
+ if (t ~ /^[{}]([ \t;].*)?$/) return 0
+ if (t ~ /^[()]([ \t].*)?$/) return 0
+ if (t ~ /^;;/) return 0
+ return 1
+}
+
+function esc(s) {
+ gsub(/&/, "\\&amp;", s)
+ gsub(/</, "\\&lt;", s)
+ gsub(/>/, "\\&gt;", s)
+ return s
+}
+
+function basename(p) { sub(/.*\//, "", p); sub(/.*\\/, "", p); return p }
+
+BEGIN {
+ FS = "\t"
+ # Load the executed-line set.
+ while ((getline ln < COVERED) > 0) {
+ nf = split(ln, a, "\t")
+ if (nf >= 2) covered[a[1] SUBSEP a[2]] = 1
+ }
+ close(COVERED)
+}
+
+# New source file: open its HTML report and reset counters.
+FNR == 1 {
+ if (cur != "") finish_file()
+ cur = FILENAME
+ curbase = basename(FILENAME)
+ total = 0; hit = 0
+ html = HTMLDIR "/" curbase ".html"
+ print "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">" > html
+ print "<title>coverage: " esc(curbase) "</title>" > html
+ print "<style>body{font:13px/1.5 monospace;margin:0;padding:1rem;background:#fff;color:#111}" > html
+ print "h1{font:600 16px sans-serif}" > html
+ print "table{border-collapse:collapse;width:100%}" > html
+ print "td{padding:0 .5rem;white-space:pre;vertical-align:top}" > html
+ print "td.n{color:#999;text-align:right;user-select:none;border-right:1px solid #ddd}" > html
+ print ".hit{background:#e6ffed}.miss{background:#ffeef0}.na{color:#999}" > html
+ print "</style></head><body>" > html
+ print "<h1>coverage: " esc(curbase) "</h1><table>" > html
+}
+
+{
+ cls = "na"
+ if (is_exec($0)) {
+ total++
+ if ((curbase SUBSEP FNR) in covered) { hit++; cls = "hit" } else { cls = "miss" }
+ }
+ print "<tr class=\"" cls "\"><td class=\"n\">" FNR "</td><td>" esc($0) "</td></tr>" > html
+}
+
+function finish_file( pct) {
+ print "</table></body></html>" > html
+ close(html)
+ pct = (total > 0 ? (hit * 100.0 / total) : 0)
+ printf "%s,%d,%d,%.1f\n", curbase, total, hit, pct
+}
+
+END { if (cur != "") finish_file() }
diff --git a/scripts/lib/render-coverage-index.awk b/scripts/lib/render-coverage-index.awk
new file mode 100644
index 0000000..cc5af1b
--- /dev/null
+++ b/scripts/lib/render-coverage-index.awk
@@ -0,0 +1,48 @@
+# scripts/lib/render-coverage-index.awk
+# Render the coverage index page from summary rows.
+# Input CSV rows (no header): "<file>,<executable>,<covered>,<percent>".
+# Per-file detail pages are expected at html/<file>.html.
+# Variables: -v TITLE="..."
+
+function esc(s) {
+ gsub(/&/, "\\&amp;", s); gsub(/</, "\\&lt;", s); gsub(/>/, "\\&gt;", s)
+ return s
+}
+
+function color(p) {
+ if (p + 0 >= 80) return "#2da44e"
+ if (p + 0 >= 50) return "#bf8700"
+ return "#cf222e"
+}
+
+BEGIN { FS = ","; n = 0; tot = 0; cov = 0 }
+
+{
+ n++
+ f[n] = $1; ex[n] = $2 + 0; cv[n] = $3 + 0; pc[n] = $4 + 0
+ tot += $2 + 0; cov += $3 + 0
+}
+
+END {
+ overall = (tot > 0 ? cov * 100.0 / tot : 0)
+ print "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">"
+ print "<title>" esc(TITLE) "</title>"
+ print "<style>body{font:14px/1.5 sans-serif;margin:0;padding:1.5rem;color:#111}"
+ print "h1{font-size:18px}.overall{font-size:15px;margin:.5rem 0 1rem}"
+ print "table{border-collapse:collapse;width:100%;max-width:820px}"
+ print "th,td{padding:.35rem .5rem;text-align:left;border-bottom:1px solid #eee}"
+ print "td.num{text-align:right;font-variant-numeric:tabular-nums}"
+ print ".track{background:#eee;border-radius:3px;height:.7em;width:160px}"
+ print ".fill{height:.7em;border-radius:3px}"
+ print "a{color:#0969da;text-decoration:none}a:hover{text-decoration:underline}"
+ print "</style></head><body>"
+ print "<h1>" esc(TITLE) "</h1>"
+ printf "<div class=\"overall\">Overall: <strong style=\"color:%s\">%.1f%%</strong> (%d/%d executable lines)</div>\n", color(overall), overall, cov, tot
+ print "<table><thead><tr><th>file</th><th>coverage</th><th>%</th><th>covered</th><th>executable</th></tr></thead><tbody>"
+ for (i = 1; i <= n; i++) {
+ printf "<tr><td><a href=\"html/%s.html\">%s</a></td>", esc(f[i]), esc(f[i])
+ printf "<td><div class=\"track\"><div class=\"fill\" style=\"width:%.0f%%;background:%s\"></div></div></td>", pc[i], color(pc[i])
+ printf "<td class=\"num\">%.1f%%</td><td class=\"num\">%d</td><td class=\"num\">%d</td></tr>\n", pc[i], cv[i], ex[i]
+ }
+ print "</tbody></table></body></html>"
+}
diff --git a/scripts/lib/render-hotspots.awk b/scripts/lib/render-hotspots.awk
new file mode 100644
index 0000000..0eb2b6d
--- /dev/null
+++ b/scripts/lib/render-hotspots.awk
@@ -0,0 +1,56 @@
+# scripts/lib/render-hotspots.awk
+# Render a per-line hotspot HTML report from sorted TSV rows.
+# Input TSV (sorted slowest-first): "<total_ms>\t<hits>\t<file>\t<line>".
+# Variables:
+# -v TITLE="..."
+# -v SRC=<path> source file for the primary target, used to show line text.
+
+function esc(s) {
+ gsub(/&/, "\\&amp;", s); gsub(/</, "\\&lt;", s); gsub(/>/, "\\&gt;", s)
+ return s
+}
+
+BEGIN {
+ FS = "\t"; n = 0
+ # Load source lines for text display (best effort).
+ if (SRC != "") {
+ i = 0
+ while ((getline ln < SRC) > 0) { i++; srctext[i] = ln }
+ close(SRC)
+ srcbase = SRC; sub(/.*\//, "", srcbase); sub(/.*\\/, "", srcbase)
+ }
+}
+
+{
+ n++
+ ms[n] = $1 + 0
+ hits[n] = $2 + 0
+ file[n] = $3
+ line[n] = $4 + 0
+ if (n == 1) maxv = $1 + 0
+}
+
+END {
+ print "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">"
+ print "<title>" esc(TITLE) "</title>"
+ print "<style>body{font:14px/1.5 sans-serif;margin:0;padding:1.5rem;color:#111}"
+ print "h1{font-size:18px}.meta{color:#555;margin-bottom:1rem}"
+ print "table{border-collapse:collapse;width:100%}"
+ print "th,td{padding:.3rem .5rem;text-align:left;border-bottom:1px solid #eee}"
+ print "td.num{text-align:right;font-variant-numeric:tabular-nums}"
+ print "code{font:12px/1.4 monospace;white-space:pre}"
+ print ".bar{height:.7em;background:#e8703a;border-radius:2px}"
+ print "</style></head><body>"
+ print "<h1>" esc(TITLE) "</h1>"
+ print "<div class=\"meta\">cumulative self-time per source line, slowest first</div>"
+ print "<table><thead><tr><th>#</th><th>ms</th><th>hits</th><th>file:line</th><th>source</th><th></th></tr></thead><tbody>"
+ for (i = 1; i <= n; i++) {
+ w = (maxv > 0 ? (ms[i] * 100.0 / maxv) : 0)
+ txt = ""
+ if (file[i] == srcbase && (line[i] in srctext)) txt = srctext[line[i]]
+ printf "<tr><td class=\"num\">%d</td><td class=\"num\">%.1f</td><td class=\"num\">%d</td>", i, ms[i], hits[i]
+ printf "<td>%s:%d</td><td><code>%s</code></td>", esc(file[i]), line[i], esc(txt)
+ printf "<td><div class=\"bar\" style=\"width:%.1f%%\"></div></td></tr>\n", w
+ }
+ print "</tbody></table></body></html>"
+}
diff --git a/scripts/lib/render-phases.awk b/scripts/lib/render-phases.awk
new file mode 100644
index 0000000..fcb11ec
--- /dev/null
+++ b/scripts/lib/render-phases.awk
@@ -0,0 +1,41 @@
+# scripts/lib/render-phases.awk
+# Render per-phase hotspot HTML from sorted TSV rows.
+# Input TSV: "<total_ms>\t<hits>\t<phase>".
+# Variables:
+# -v TITLE="..."
+
+function esc(s) {
+ gsub(/&/, "\\&amp;", s); gsub(/</, "\\&lt;", s); gsub(/>/, "\\&gt;", s)
+ return s
+}
+
+BEGIN { FS = "\t"; n = 0 }
+
+{
+ n++
+ ms[n] = $1 + 0
+ hits[n] = $2 + 0
+ phase[n] = $3
+ if (n == 1) maxv = ms[n]
+}
+
+END {
+ print "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">"
+ print "<title>" esc(TITLE) "</title>"
+ print "<style>body{font:14px/1.5 sans-serif;margin:0;padding:1.5rem;color:#111}"
+ print "h1{font-size:18px}.meta{color:#555;margin-bottom:1rem}"
+ print "table{border-collapse:collapse;width:100%}"
+ print "th,td{padding:.3rem .5rem;text-align:left;border-bottom:1px solid #eee}"
+ print "td.num{text-align:right;font-variant-numeric:tabular-nums}"
+ print ".bar{height:.7em;background:#4cbf8f;border-radius:2px}"
+ print "</style></head><body>"
+ print "<h1>" esc(TITLE) "</h1>"
+ print "<div class=\"meta\">cumulative self-time by pipeline phase, slowest first</div>"
+ print "<table><thead><tr><th>#</th><th>ms</th><th>hits</th><th>phase</th><th></th></tr></thead><tbody>"
+ for (i = 1; i <= n; i++) {
+ w = (maxv > 0 ? (ms[i] * 100.0 / maxv) : 0)
+ printf "<tr><td class=\"num\">%d</td><td class=\"num\">%.1f</td><td class=\"num\">%d</td><td>%s</td>", i, ms[i], hits[i], esc(phase[i])
+ printf "<td><div class=\"bar\" style=\"width:%.1f%%\"></div></td></tr>\n", w
+ }
+ print "</tbody></table></body></html>"
+}
diff --git a/scripts/lib/render-timing.awk b/scripts/lib/render-timing.awk
new file mode 100644
index 0000000..ffd807f
--- /dev/null
+++ b/scripts/lib/render-timing.awk
@@ -0,0 +1,41 @@
+# scripts/lib/render-timing.awk
+# Render a per-test timing HTML report from sorted TSV rows.
+# Input TSV (already sorted slowest-first): "<seconds>\t<suite>\t<test>".
+# Variables: -v TITLE="..."
+
+function esc(s) {
+ gsub(/&/, "\\&amp;", s); gsub(/</, "\\&lt;", s); gsub(/>/, "\\&gt;", s)
+ return s
+}
+
+BEGIN { FS = "\t"; n = 0; total = 0 }
+
+{
+ n++
+ sec[n] = $1 + 0
+ suite[n] = $2
+ name[n] = $3
+ total += $1 + 0
+ if (n == 1) maxv = $1 + 0
+}
+
+END {
+ print "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">"
+ print "<title>" esc(TITLE) "</title>"
+ print "<style>body{font:14px/1.5 sans-serif;margin:0;padding:1.5rem;color:#111}"
+ print "h1{font-size:18px}.meta{color:#555;margin-bottom:1rem}"
+ print "table{border-collapse:collapse;width:100%}"
+ print "th,td{padding:.3rem .5rem;text-align:left;border-bottom:1px solid #eee}"
+ print "td.num{text-align:right;font-variant-numeric:tabular-nums}"
+ print ".bar{height:.7em;background:#4c8bf5;border-radius:2px}"
+ print "</style></head><body>"
+ print "<h1>" esc(TITLE) "</h1>"
+ printf "<div class=\"meta\">%d tests &middot; %.3f s total</div>\n", n, total
+ print "<table><thead><tr><th>#</th><th>seconds</th><th>test</th><th></th></tr></thead><tbody>"
+ for (i = 1; i <= n; i++) {
+ w = (maxv > 0 ? (sec[i] * 100.0 / maxv) : 0)
+ printf "<tr><td class=\"num\">%d</td><td class=\"num\">%.3f</td><td>%s</td>", i, sec[i], esc(name[i])
+ printf "<td><div class=\"bar\" style=\"width:%.1f%%\"></div></td></tr>\n", w
+ }
+ print "</tbody></table></body></html>"
+}
diff --git a/scripts/lib/timing-extract.awk b/scripts/lib/timing-extract.awk
new file mode 100644
index 0000000..9cb15d7
--- /dev/null
+++ b/scripts/lib/timing-extract.awk
@@ -0,0 +1,32 @@
+# scripts/lib/timing-extract.awk
+# Extract per-test durations from bats JUnit report XML.
+#
+# Emits TSV: "<seconds>\t<suite>\t<test name>" for each <testcase>.
+# The caller sorts and renders. Handles testcase elements that span the line
+# regardless of whether they are self-closing or contain <failure> children.
+
+function attr(line, key, re, s) {
+ re = key "=\""
+ if (index(line, re) == 0) return ""
+ s = line
+ sub(".*" re, "", s) # drop up to opening quote
+ sub(/".*/, "", s) # drop from closing quote
+ return s
+}
+
+function unesc(s) {
+ gsub(/&quot;/, "\"", s)
+ gsub(/&apos;/, "'", s)
+ gsub(/&lt;/, "<", s)
+ gsub(/&gt;/, ">", s)
+ gsub(/&amp;/, "\\&", s)
+ return s
+}
+
+/<testcase[ \t]/ {
+ name = unesc(attr($0, "name"))
+ cls = unesc(attr($0, "classname"))
+ t = attr($0, "time")
+ if (t == "") t = "0"
+ printf "%s\t%s\t%s\n", t, cls, name
+}
diff --git a/scripts/lib/trace-extract.awk b/scripts/lib/trace-extract.awk
new file mode 100644
index 0000000..7b8682a
--- /dev/null
+++ b/scripts/lib/trace-extract.awk
@@ -0,0 +1,71 @@
+# scripts/lib/trace-extract.awk
+# Extract instrumented source locations from xtrace logs produced via
+# scripts/lib/trace-init.sh.
+#
+# Marker lines look like (bash repeats the leading '@' once per nesting level):
+# @@TBL@@ /path/to/trbldoc.sh:1234 1712345678.123456
+#
+# Variables:
+# MODE=cov (default) -> prints "<base>\t<line>" per marker line
+# MODE=time -> prints "<total_ms>\t<hits>\t<base>\t<line>" at END,
+# computing per-line durations from consecutive markers
+# WITHIN each trace file (one file == one process, so
+# parallel xargs workers are not cross-attributed).
+
+function basename(p) {
+ sub(/.*\//, "", p) # strip up to last forward slash
+ sub(/.*\\/, "", p) # strip up to last backslash (defensive)
+ return p
+}
+
+# Parse a marker line into globals g_base and g_line (and g_epoch for time).
+# Returns 1 on success, 0 if the line is not a usable marker.
+function parse(line, s, n, f, loc, rest) {
+ if (line !~ /@+TBL@@ /) return 0
+ s = line
+ sub(/^.*TBL@@ /, "", s) # drop marker and everything before it
+ n = split(s, f, " ")
+ if (n < 1) return 0
+ loc = f[1] # <source>:<lineno>
+ if (!match(loc, /:[0-9]+$/)) return 0
+ g_line = substr(loc, RSTART + 1)
+ g_base = basename(substr(loc, 1, RSTART - 1))
+ if (MODE == "time") {
+ g_epoch = (n >= 2 ? f[2] : "")
+ }
+ return 1
+}
+
+BEGIN { if (MODE == "") MODE = "cov" }
+
+# Reset per-process state at the start of each trace file.
+FNR == 1 { have_prev = 0 }
+
+{
+ if (!parse($0)) next
+ if (MODE == "cov") {
+ print g_base "\t" g_line
+ next
+ }
+ # MODE == time: attribute elapsed time to the PREVIOUS marker.
+ if (have_prev && g_epoch != "" && prev_epoch != "") {
+ d = g_epoch - prev_epoch
+ if (d < 0) d = 0
+ key = prev_base SUBSEP prev_line
+ ms[key] += d * 1000.0
+ hits[key] += 1
+ base_of[key] = prev_base
+ line_of[key] = prev_line
+ }
+ prev_base = g_base
+ prev_line = g_line
+ prev_epoch = g_epoch
+ have_prev = 1
+}
+
+END {
+ if (MODE != "time") exit
+ for (k in ms) {
+ printf "%.3f\t%d\t%s\t%s\n", ms[k], hits[k], base_of[k], line_of[k]
+ }
+}
diff --git a/scripts/lib/trace-init.sh b/scripts/lib/trace-init.sh
new file mode 100644
index 0000000..80f1ab0
--- /dev/null
+++ b/scripts/lib/trace-init.sh
@@ -0,0 +1,42 @@
+# scripts/lib/trace-init.sh
+# Instrumentation hook sourced automatically by every non-interactive bash
+# invocation via the BASH_ENV environment variable.
+#
+# The bats suite runs the tool with `bash "$TRBLDOC"`, so exporting
+# BASH_ENV=<this file> causes each trbldoc.sh process to enable xtrace at
+# startup WITHOUT modifying trbldoc.sh or the test files. The emitted trace is
+# consumed by the coverage and hotspot aggregators.
+#
+# Activated only when TRBLDOC_TRACE_DIR is set, and only for scripts whose
+# basename appears in TRBLDOC_TRACE_TARGETS (default: trbldoc.sh). This keeps
+# unrelated bash helpers (md2html stub, bats internals) out of the trace.
+#
+# TRBLDOC_TRACE_MODE selects the PS4 format:
+# cov -> "<marker> <source>:<lineno>" (line coverage)
+# time -> "<marker> <source>:<lineno> <EPOCHREALTIME>" (line timing; bash>=5)
+#
+# Every traced line is prefixed with the marker token @@TBL@@. bash repeats the
+# FIRST PS4 character once per call-nesting level, so the aggregators match the
+# marker with a tolerant pattern rather than a fixed prefix.
+
+if [ -n "${TRBLDOC_TRACE_DIR:-}" ]; then
+ __tb_self="$0"
+ __tb_base="${__tb_self##*/}"
+ case " ${TRBLDOC_TRACE_TARGETS:-trbldoc.sh} " in
+ *" ${__tb_base} "*)
+ # Dedicated append fd per process; PID keeps parallel xargs workers apart.
+ __tb_trace_file="${TRBLDOC_TRACE_DIR}/trace.$$.log"
+ # shellcheck disable=SC3023
+ exec 7>>"$__tb_trace_file"
+ export BASH_XTRACEFD=7
+ if [ "${TRBLDOC_TRACE_MODE:-cov}" = "time" ]; then
+ # EPOCHREALTIME is bash>=5; used only for the hotspot profiler.
+ PS4='@@TBL@@ ${BASH_SOURCE}:${LINENO} ${EPOCHREALTIME} '
+ else
+ PS4='@@TBL@@ ${BASH_SOURCE}:${LINENO} '
+ fi
+ export PS4
+ set -x
+ ;;
+ esac
+fi
diff --git a/scripts/lib/trace-phase-extract.awk b/scripts/lib/trace-phase-extract.awk
new file mode 100644
index 0000000..429f8cf
--- /dev/null
+++ b/scripts/lib/trace-phase-extract.awk
@@ -0,0 +1,75 @@
+# scripts/lib/trace-phase-extract.awk
+# Aggregate xtrace timing markers into coarse trbldoc pipeline phases.
+#
+# Input: trace.*.log files emitted by scripts/lib/trace-init.sh in time mode.
+# Output rows: "<total_ms>\t<hits>\t<phase>" at END.
+#
+# Required -v vars:
+# SRC_BASE
+# L_DISCOVER_END
+# L_EXTRACT_END
+# L_ORDER_END
+# L_RENDER_END
+# L_NAV_END
+# L_REFS_END
+# L_TOC_END
+# L_ASSEMBLE_END
+
+function basename(p) {
+ sub(/.*\//, "", p)
+ sub(/.*\\/, "", p)
+ return p
+}
+
+function parse(line, s, n, f, loc) {
+ if (line !~ /@+TBL@@ /) return 0
+ s = line
+ sub(/^.*TBL@@ /, "", s)
+ n = split(s, f, " ")
+ if (n < 2) return 0
+ loc = f[1]
+ if (!match(loc, /:[0-9]+$/)) return 0
+ g_line = substr(loc, RSTART + 1) + 0
+ g_base = basename(substr(loc, 1, RSTART - 1))
+ g_epoch = f[2]
+ return 1
+}
+
+function phase_for(line) {
+ if (line <= L_DISCOVER_END) return "discover_sources"
+ if (line <= L_EXTRACT_END) return "extract_chunks"
+ if (line <= L_ORDER_END) return "build_render_order"
+ if (line <= L_RENDER_END) return "render_chunks"
+ if (line <= L_NAV_END) return "build_nav"
+ if (line <= L_REFS_END) return "resolve_refs"
+ if (line <= L_TOC_END) return "apply_toc"
+ if (line <= L_ASSEMBLE_END) return "assemble_site"
+ return "other"
+}
+
+BEGIN {
+ if (SRC_BASE == "") SRC_BASE = "trbldoc.sh"
+}
+
+FNR == 1 { have_prev = 0 }
+
+{
+ if (!parse($0)) next
+ if (g_base != SRC_BASE) next
+ if (have_prev && g_epoch != "" && prev_epoch != "") {
+ d = g_epoch - prev_epoch
+ if (d < 0) d = 0
+ ph = phase_for(prev_line)
+ ms[ph] += d * 1000.0
+ hits[ph] += 1
+ }
+ prev_line = g_line
+ prev_epoch = g_epoch
+ have_prev = 1
+}
+
+END {
+ for (ph in ms) {
+ printf "%.3f\t%d\t%s\n", ms[ph], hits[ph], ph
+ }
+}