Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .claude/skills/compare-examples-size/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
name: compare-examples-size
description: Compare maxGraph example bundle sizes between two git references (commit SHA, branch, or tag) and emit a markdown table with deltas in kB and %. Use when the user asks to compare bundle sizes, measure the size impact of a change or release, diff example sizes between branches/tags/commits, or invokes the /compare-examples-size slash command with two refs.
---

# Compare Examples Size

## Overview

Builds the maxGraph examples at two git references and produces a markdown table comparing per-example bundle sizes. Useful to assess the size impact of a PR, refactor, or release.

## When to use

Trigger this skill on requests like:
- "compare bundle sizes between `main` and my branch"
- "what's the size impact of this branch?"
- "compare example sizes between `v1.2.0` and `main`"
- `/compare-examples-size <from> <to>`

## Inputs

Two git references — `<from>` and `<to>`. Each can be:
- a commit SHA (full or short)
- a branch name (HEAD of the branch is used)
- a tag name

If the user only provides one ref, ask for the second. Do not assume `HEAD` silently. If the user provides none, ask for both.

## Workflow

Run the bundled script with the two refs:

```bash
.claude/skills/compare-examples-size/scripts/compare-examples-size.bash <from> <to>
```

The script:
1. Verifies the working tree is strictly clean (`git status --porcelain` empty). Aborts with an error if not.
2. Resolves both refs via `git rev-parse --verify`. Aborts on unknown refs.
3. Normalizes the column order: the **older** commit (by committer timestamp) becomes column 1, the **newer** becomes column 2 — regardless of the order the user passed them on the CLI. A note is printed to stderr if a swap happened. Δ is therefore always `newer − older` (positive Δ = size grew over time).
4. Saves the current branch (or SHA if detached) and installs a `trap` so the original ref is restored on any exit — including build failures or Ctrl-C.
5. For each ref, in order older then newer:
- `git checkout <ref>`
- `npm ci` if `package-lock.json` differs from the previous ref (always runs on first ref to guarantee fresh deps). `npm ci` is used instead of `npm install` so the lock file is never rewritten — keeping the working tree clean across the checkout.
- `npm run build -w packages/core`
- `./scripts/build-all-examples.bash`, capturing its trailing CSV (header line + values line) into a temp file.
6. Parses both CSVs, joins by example name, and prints a markdown table to stdout.
7. Restores the original ref and removes the temp dir.

Build logs go to stderr; only the final markdown table goes to stdout, so it can be redirected to a file or piped directly.

## Output format

GitHub-flavored markdown table on stdout:

```
| Example | <older-label> <older-short-sha> (kB) | <newer-label> <newer-short-sha> (kB) | Δ kB | Δ % |
```

- Column 1 is always the **older** commit, column 2 the **newer** — even if the user passed them in the reverse order on the CLI.
- `<label>` is the branch or tag name the user supplied. If the user supplied a raw SHA, the label is omitted and only the short SHA appears.
- `Δ kB` is signed (`+` or `-`) and equals `newer − older`.
- `Δ %` is signed and uses the older size as the denominator.
- Rows are sorted alphabetically by example name.
- `N/A` is used when an example exists at one ref but not the other.

No commentary on causes of variation — just the numbers.

## Prerequisites and constraints

- The script must run from inside the maxGraph repository (it uses `git rev-parse --show-toplevel`).
- The active Node.js version should match `.nvmrc`. If `nvm` is available, the user should run `nvm use` before invoking the skill.
- The working tree must be clean. Do **not** auto-stash. If the user has uncommitted work, surface the error and ask them how to proceed.
- The script does two full `npm run build -w packages/core` + full examples build, so it takes several minutes. Warn the user before launching if this is the first run in a session.

## Failure handling

- **Dirty working tree**: report the offending files (`git status --short`) and stop.
- **Unknown ref**: report which ref failed to resolve and stop.
- **Build failure on a ref**: the `trap` restores the original ref before exiting. Report which ref failed and at which step (`npm ci`, core build, or examples build). Do not retry automatically.
- **Original ref restoration fails on exit**: surface the warning printed by the script and tell the user to `git checkout <original-ref>` manually.
- **Forced kill (SIGKILL) mid-run**: bash traps cannot run on SIGKILL, so the user can be left in detached HEAD. To detect this, the script writes a lock file at `.git/compare-examples-size.lock` containing the original ref before any checkout; the `trap` removes it on graceful exit. If the script aborts via SIGKILL, the lock survives. The next invocation will refuse to start and print the original ref to restore. After running `git checkout <that-ref>`, the user must remove the lock file as instructed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
#!/usr/bin/env bash
set -euo pipefail

# Compare maxGraph example bundle sizes between two git references.
#
# Usage: compare-examples-size.bash <from-ref> <to-ref>
# <from-ref> / <to-ref>: commit SHA, branch name, or tag.
#
# Output: a markdown table on stdout with per-example sizes for both refs and the delta.
# Build logs are printed to stderr so stdout stays clean.

usage() {
echo "Usage: $0 <from-ref> <to-ref>" >&2
echo " Each ref can be a commit SHA, branch name, or tag." >&2
}

if [[ $# -ne 2 ]]; then
usage
exit 2
fi

FROM_RAW="$1"
TO_RAW="$2"

REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "$REPO_ROOT" ]]; then
echo "Error: not inside a git repository." >&2
exit 1
fi
cd "$REPO_ROOT"

# 1. Recovery lock check — must run FIRST. If a previous invocation was killed forcibly
# (SIGKILL bypasses the trap), the lock survives and points to the ref the user was on.
# Surface that before any other check so the recovery instructions are the first thing
# the user sees.
LOCK_FILE="$(git rev-parse --git-common-dir)/compare-examples-size.lock"
if [[ -f "$LOCK_FILE" ]]; then
prev_ref=$(cat "$LOCK_FILE" 2>/dev/null || echo "<unknown>")
echo "Error: a previous run did not clean up (lock file present)." >&2
echo "It was started on ref: $prev_ref" >&2
echo "If you are not on that ref now, run: git checkout $prev_ref" >&2
echo "Then remove the lock: rm $LOCK_FILE" >&2
exit 1
fi

# 2. Strict clean working tree (including untracked files in tracked dirs)
if [[ -n "$(git status --porcelain)" ]]; then
echo "Error: working tree is not clean. Commit, stash, or discard changes before running." >&2
git status --short >&2
exit 1
fi

# 3. Resolve refs (verify they exist; capture commit SHA + label).
# The `^{commit}` peel forces dereferencing through annotated tags so we end up with the
# underlying commit SHA, not the tag object SHA. Without it, `git rev-parse --verify v0.23.0`
# returns the tag object's SHA, which is unfindable via `git log` and confuses readers of the
# output table.
resolve_ref() {
local raw="$1"
local sha
if ! sha=$(git rev-parse --verify "$raw^{commit}" 2>/dev/null); then
echo "Error: cannot resolve git ref '$raw' to a commit." >&2
exit 1
fi
echo "$sha"
}

FROM_SHA=$(resolve_ref "$FROM_RAW")
TO_SHA=$(resolve_ref "$TO_RAW")

# Normalize order: older commit first (column 1), newer commit second (column 2),
# regardless of CLI argument order. "Older" is determined by committer timestamp,
# which works even when the two refs are not in an ancestor relationship.
# Δ semantics become "newer minus older" → positive Δ means size grew over time.
FROM_TS=$(git show -s --format=%ct "$FROM_SHA")
TO_TS=$(git show -s --format=%ct "$TO_SHA")
if (( FROM_TS > TO_TS )); then
echo "Note: swapping args so the older commit is in column 1 (was: '$FROM_RAW' newer than '$TO_RAW')." >&2
tmp_raw="$FROM_RAW"; FROM_RAW="$TO_RAW"; TO_RAW="$tmp_raw"
tmp_sha="$FROM_SHA"; FROM_SHA="$TO_SHA"; TO_SHA="$tmp_sha"
fi
FROM_SHORT="${FROM_SHA:0:7}"
TO_SHORT="${TO_SHA:0:7}"

# Decide the label shown in the markdown header: empty unless user provided a branch/tag name.
# A raw 40-char (or shorter) SHA passed in is treated as a SHA-only column.
ref_label() {
local raw="$1"
local sha="$2"
# If raw looks like a SHA (hex, 7-40 chars) AND matches the start of sha, treat as SHA-only.
if [[ "$raw" =~ ^[0-9a-fA-F]{7,40}$ ]] && [[ "$sha" == "$raw"* ]]; then
echo ""
else
echo "$raw"
fi
}

FROM_LABEL=$(ref_label "$FROM_RAW" "$FROM_SHA")
TO_LABEL=$(ref_label "$TO_RAW" "$TO_SHA")

# 4. Save current ref so we can restore it on exit (branch name preferred, fallback to SHA),
# then write the lock so a forced kill leaves a recovery breadcrumb (see step 1).
ORIGINAL_REF=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || git rev-parse HEAD)
echo "$ORIGINAL_REF" > "$LOCK_FILE"

# Temp files for CSV captures
TMP_DIR=$(mktemp -d -t maxgraph-sizes-XXXXXX)
FROM_CSV="$TMP_DIR/from.csv"
TO_CSV="$TMP_DIR/to.csv"

cleanup() {
local status=$?
echo >&2
echo "Restoring original ref: $ORIGINAL_REF" >&2
git checkout --quiet "$ORIGINAL_REF" 2>/dev/null || \
echo "Warning: failed to restore $ORIGINAL_REF. Run 'git checkout $ORIGINAL_REF' manually." >&2
rm -rf "$TMP_DIR"
rm -f "$LOCK_FILE"
exit $status
}
trap cleanup EXIT INT TERM
Comment on lines +101 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Install the cleanup trap before creating side effects.

The lock file (line 104) and TMP_DIR (line 107) are created before trap cleanup EXIT INT TERM is installed on line 121. If SIGINT/SIGTERM arrives in that window (e.g., during mktemp), the shell takes the default action and exits without running cleanup, leaving an orphaned lock file and a temp dir behind — and on next run the user is told a previous run was killed even though nothing was checked out. Move the trap installation to immediately after ORIGINAL_REF is captured, then write the lock and create the temp dir.

🛡️ Proposed reordering
 ORIGINAL_REF=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || git rev-parse HEAD)
-echo "$ORIGINAL_REF" > "$LOCK_FILE"
-
-# Temp files for CSV captures
-TMP_DIR=$(mktemp -d -t maxgraph-sizes-XXXXXX)
-FROM_CSV="$TMP_DIR/from.csv"
-TO_CSV="$TMP_DIR/to.csv"
+TMP_DIR=""
 
 cleanup() {
   local status=$?
+  trap - EXIT INT TERM
   echo >&2
   echo "Restoring original ref: $ORIGINAL_REF" >&2
   git checkout --quiet "$ORIGINAL_REF" 2>/dev/null || \
     echo "Warning: failed to restore $ORIGINAL_REF. Run 'git checkout $ORIGINAL_REF' manually." >&2
-  rm -rf "$TMP_DIR"
+  [[ -n "$TMP_DIR" ]] && rm -rf "$TMP_DIR"
   rm -f "$LOCK_FILE"
   exit $status
 }
 trap cleanup EXIT INT TERM
+
+echo "$ORIGINAL_REF" > "$LOCK_FILE"
+TMP_DIR=$(mktemp -d -t maxgraph-sizes-XXXXXX)
+FROM_CSV="$TMP_DIR/from.csv"
+TO_CSV="$TMP_DIR/to.csv"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# 4. Save current ref so we can restore it on exit (branch name preferred, fallback to SHA),
# then write the lock so a forced kill leaves a recovery breadcrumb (see step 1).
ORIGINAL_REF=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || git rev-parse HEAD)
echo "$ORIGINAL_REF" > "$LOCK_FILE"
# Temp files for CSV captures
TMP_DIR=$(mktemp -d -t maxgraph-sizes-XXXXXX)
FROM_CSV="$TMP_DIR/from.csv"
TO_CSV="$TMP_DIR/to.csv"
cleanup() {
local status=$?
echo >&2
echo "Restoring original ref: $ORIGINAL_REF" >&2
git checkout --quiet "$ORIGINAL_REF" 2>/dev/null || \
echo "Warning: failed to restore $ORIGINAL_REF. Run 'git checkout $ORIGINAL_REF' manually." >&2
rm -rf "$TMP_DIR"
rm -f "$LOCK_FILE"
exit $status
}
trap cleanup EXIT INT TERM
# 4. Save current ref so we can restore it on exit (branch name preferred, fallback to SHA),
# then write the lock so a forced kill leaves a recovery breadcrumb (see step 1).
ORIGINAL_REF=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || git rev-parse HEAD)
TMP_DIR=""
cleanup() {
local status=$?
trap - EXIT INT TERM
echo >&2
echo "Restoring original ref: $ORIGINAL_REF" >&2
git checkout --quiet "$ORIGINAL_REF" 2>/dev/null || \
echo "Warning: failed to restore $ORIGINAL_REF. Run 'git checkout $ORIGINAL_REF' manually." >&2
[[ -n "$TMP_DIR" ]] && rm -rf "$TMP_DIR"
rm -f "$LOCK_FILE"
exit $status
}
trap cleanup EXIT INT TERM
echo "$ORIGINAL_REF" > "$LOCK_FILE"
# Temp files for CSV captures
TMP_DIR=$(mktemp -d -t maxgraph-sizes-XXXXXX)
FROM_CSV="$TMP_DIR/from.csv"
TO_CSV="$TMP_DIR/to.csv"

Comment on lines +111 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

cleanup runs twice on INT/TERM.

With trap cleanup EXIT INT TERM, a SIGINT invokes cleanup, which calls exit $status, which in turn fires the EXIT trap and runs cleanup again. The second pass re-prints "Restoring original ref: …", attempts another checkout (harmless but noisy), and tries to rm -f an already-removed lock. Clear the traps at the top of cleanup to make it idempotent.

♻️ Proposed fix
 cleanup() {
   local status=$?
+  trap - EXIT INT TERM
   echo >&2
   echo "Restoring original ref: $ORIGINAL_REF" >&2
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cleanup() {
local status=$?
echo >&2
echo "Restoring original ref: $ORIGINAL_REF" >&2
git checkout --quiet "$ORIGINAL_REF" 2>/dev/null || \
echo "Warning: failed to restore $ORIGINAL_REF. Run 'git checkout $ORIGINAL_REF' manually." >&2
rm -rf "$TMP_DIR"
rm -f "$LOCK_FILE"
exit $status
}
trap cleanup EXIT INT TERM
cleanup() {
local status=$?
trap - EXIT INT TERM
echo >&2
echo "Restoring original ref: $ORIGINAL_REF" >&2
git checkout --quiet "$ORIGINAL_REF" 2>/dev/null || \
echo "Warning: failed to restore $ORIGINAL_REF. Run 'git checkout $ORIGINAL_REF' manually." >&2
rm -rf "$TMP_DIR"
rm -f "$LOCK_FILE"
exit $status
}
trap cleanup EXIT INT TERM


PREV_LOCK_HASH=""

# 4. Build at each ref and capture CSV
build_and_capture() {
local sha="$1"
local out_csv="$2"
local raw="$3"

echo >&2
echo "=== Checking out $raw ($sha) ===" >&2
git checkout --quiet "$sha"

local current_lock_hash=""
if [[ -f package-lock.json ]]; then
current_lock_hash=$(git hash-object package-lock.json)
fi

if [[ -z "$PREV_LOCK_HASH" || "$PREV_LOCK_HASH" != "$current_lock_hash" ]]; then
# Use `npm ci` rather than `npm install` so the working tree stays clean:
# `npm install` can rewrite package-lock.json and contaminate the tree across the checkout.
echo "--- npm ci (package-lock.json changed or first run) ---" >&2
npm ci >&2
else
echo "--- npm ci skipped (package-lock.json unchanged) ---" >&2
fi
PREV_LOCK_HASH="$current_lock_hash"

echo "--- Building core ---" >&2
npm run build -w packages/core >&2

echo "--- Building examples and capturing sizes ---" >&2
# build-all-examples.bash emits a CSV at the end: a header line and a values line.
# Stream to stderr for the user while teeing to a tmp file we parse afterwards.
local raw_out
raw_out=$(mktemp -t maxgraph-raw-XXXXXX)
./scripts/build-all-examples.bash 2>&1 | tee /dev/stderr > "$raw_out"

# Extract the last two non-empty lines, which are the CSV header and CSV values.
local last_two
last_two=$(grep -v '^$' "$raw_out" | tail -n 2)
echo "$last_two" > "$out_csv"
rm -f "$raw_out"

if [[ $(wc -l < "$out_csv") -lt 2 ]]; then
echo "Error: failed to capture CSV output from build-all-examples.bash." >&2
exit 1
fi
}

build_and_capture "$FROM_SHA" "$FROM_CSV" "$FROM_RAW"
build_and_capture "$TO_SHA" "$TO_CSV" "$TO_RAW"

# 5. Parse CSVs and emit markdown table.
# Each CSV file has exactly 2 lines: header (example names) and values (kB sizes).
python3 - "$FROM_CSV" "$TO_CSV" "$FROM_LABEL" "$FROM_SHORT" "$TO_LABEL" "$TO_SHORT" <<'PY'
import sys

from_csv, to_csv, from_label, from_short, to_label, to_short = sys.argv[1:]

def load(path):
with open(path) as f:
lines = [ln.rstrip("\n") for ln in f if ln.strip()]
header = lines[0].split(",")
values = lines[1].split(",")
return dict(zip(header, values))
Comment on lines +182 to +187

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Silent column mismatch in CSV parsing.

dict(zip(header, values)) silently truncates to the shorter of the two lists. If build-all-examples.bash ever emits a CSV with a header/values length mismatch (or trailing garbage line picked up by tail -n 2), the script will produce a partial table with no warning. A length check before zipping turns this into a loud failure that matches the rest of the script's strict posture.

🛡️ Proposed fix
 def load(path):
     with open(path) as f:
         lines = [ln.rstrip("\n") for ln in f if ln.strip()]
     header = lines[0].split(",")
     values = lines[1].split(",")
+    if len(header) != len(values):
+        sys.exit(
+            f"Error: CSV header/values column count mismatch in {path} "
+            f"({len(header)} vs {len(values)})."
+        )
     return dict(zip(header, values))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def load(path):
with open(path) as f:
lines = [ln.rstrip("\n") for ln in f if ln.strip()]
header = lines[0].split(",")
values = lines[1].split(",")
return dict(zip(header, values))
def load(path):
with open(path) as f:
lines = [ln.rstrip("\n") for ln in f if ln.strip()]
header = lines[0].split(",")
values = lines[1].split(",")
if len(header) != len(values):
sys.exit(
f"Error: CSV header/values column count mismatch in {path} "
f"({len(header)} vs {len(values)})."
)
return dict(zip(header, values))


from_sizes = load(from_csv)
to_sizes = load(to_csv)

def to_float(v):
try:
return float(v)
except (TypeError, ValueError):
return None

def col_header(label, short):
return f"{label} {short}".strip() if label else short

from_col = col_header(from_label, from_short)
to_col = col_header(to_label, to_short)

examples = sorted(set(from_sizes) | set(to_sizes))

print()
print(f"| Example | {from_col} (kB) | {to_col} (kB) | Δ kB | Δ % |")
print("| --- | ---: | ---: | ---: | ---: |")

for name in examples:
fv = to_float(from_sizes.get(name))
tv = to_float(to_sizes.get(name))
fv_s = f"{fv:.2f}" if fv is not None else "N/A"
tv_s = f"{tv:.2f}" if tv is not None else "N/A"
if fv is not None and tv is not None:
delta = tv - fv
pct = (delta / fv * 100) if fv != 0 else 0.0
delta_s = f"{delta:+.2f}"
pct_s = f"{pct:+.2f}%"
else:
delta_s = "N/A"
pct_s = "N/A"
print(f"| {name} | {fv_s} | {tv_s} | {delta_s} | {pct_s} |")
print()
PY
78 changes: 78 additions & 0 deletions packages/website/docs/development/tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
description: Internal tools to help during maxGraph development.
---

# Development tools

This page documents internal scripts and helpers used during maxGraph development. They are not part of the public API of the library — they are intended for maintainers and contributors who need to measure, inspect, or compare the impact of their changes on the project.

## Build all examples

The script `scripts/build-all-examples.bash` builds every example shipped in the repository (`packages/ts-example*` and `packages/js-example*`) and reports the resulting bundle sizes.

It is useful to:

- give the size of the `maxGraph` chunk for all examples;
- visualize the tree-shaking effect of the examples, and observe how much each example reduces the size of the `maxGraph` chunk.

### Prerequisites

The `@maxgraph/core` package must be built first so that the examples pick up the latest changes:

```bash
npm run build -w packages/core
```

### Usage

Run from the repository root:

```bash
./scripts/build-all-examples.bash
```

The script prints, for each example:

- the size of every `*.js` file produced under `dist/`;
- a Markdown summary table with the bundle sizes of every example (with an empty "before" column intended to be filled in manually when comparing snapshots);
- a CSV summary (one header line listing example names, one value line listing sizes in kB) — convenient to copy into a spreadsheet.

To skip the build step and only display the sizes from an existing `dist/` output:

```bash
./scripts/build-all-examples.bash --list-size-only
```

## Comparing the size of the maxGraph chunk between git revisions

Comparing the size of the `maxGraph` chunk between git revisions helps track the evolution of the codebase, the reduction of the size of `maxGraph`, and the impact of tree-shaking improvements over time.

It is also useful to:

- enrich pull request descriptions with a concrete measurement of the impact of a code change on the bundle size;
- support release notes, where the resulting table can demonstrate positive changes or warn readers about negative impacts on the bundle size.

Two interchangeable entry points are provided:

- a **Claude Code skill** named `compare-examples-size`, intended for users of Claude Code. It produces a Markdown table comparing the size of the `maxGraph` chunk for all examples between two git revisions (commit SHAs, branch names, or tag names).
- the underlying **bash script**, available for direct use at `.claude/skills/compare-examples-size/scripts/compare-examples-size.bash`. The skill is a thin wrapper around this script, so contributors who do not use Claude Code can still run the comparison from any shell.

### Usage

Run from the repository root, passing two git references in any order:

```bash
.claude/skills/compare-examples-size/scripts/compare-examples-size.bash <ref-1> <ref-2>
```

For example, to compare the current `main` branch against the `v0.23.0` release tag:

```bash
.claude/skills/compare-examples-size/scripts/compare-examples-size.bash main v0.23.0
```

For each of the two references, the script checks out the revision, runs `npm ci`, builds `@maxgraph/core`, and runs `scripts/build-all-examples.bash` to capture the bundle sizes. It then prints a Markdown table to stdout — build logs go to stderr — with one row per example and the following columns: example name, bundle size at the older revision, bundle size at the newer revision, delta in kB, and delta in %.

The column order is normalized by the commit date so that the older revision is always in column 1 and the newer one in column 2, regardless of the argument order. The delta is therefore always `newer − older`: a positive delta means the bundle grew between the two revisions.

The working tree must be clean before running. The original branch is restored automatically at the end of the run, including on Ctrl-C or build failure.