Files
wanderbooks/justfile
T
swaits 13282cf80e feat(arap): terms, numbers and line accounts in the core (P4b S4.1)
Documents could name only one revenue or general-expenses account per
project, had no number of their own, and took a due date the caller had
to work out. This slice gives the core the pieces the rest of S4 needs,
and journals nothing: an old-shape document still writes byte for byte,
so the owner's books need no upgrade for it.

- Payment terms (B-39): Net n, Net n EOM, due on receipt and a custom
  date, with the early-payment discount and its exact bound
  (kernel::discount_max, Kani + bridge). A due date past the calendar's
  end is refused, never a panic, and a discount is refused with terms
  that have no net days.
- Invoice numbers (B-40, A-2): first_number plus the count of numbered
  invoices, zero-padded to four digits and wider past 9,999, stored at
  issue, kept by a void; legacy invoices are uncounted.
- Line accounts (A-10): a document line may name its account, and the
  entry posts one line per (account, project) part. An invoice line takes
  revenue or a liability holding no role (deferred revenue); a bill line
  an expense or a non-cash asset holding no role. Roles, cash accounts
  and headers are refused.
- A line's amount is exactly its quantity times its unit price
  (kernel::line_amount, Kani + bridge): both or neither, never rounded.
- A vendor's invoice number names one standing bill per vendor, with
  case, spaces and hyphens ignored, unless a duplicate reason says why
  (B-40).
- The record fields S4.3 will journal: number, terms, bill-to and seller
  snapshots, customer reference, memo, the empty sends list S5 fills, and
  the bill's received_on, all optional and defaulted.

The store refuses a line carrying an account, quantity or unit price
before it rehearses or journals anything: those fields are journaled from
S4.3's documents_v2 on, and I-3 will not let a public write replay
without them. `document_line_fields_wait_for_documents_v2` proves the
refusal, the unchanged journal and allocators, and that an ordinary line
journals exactly as before. S4.3 removes this guard in the same change
that journals the fields and adds its byte-for-byte test.

The document commands keep their signatures; create_invoice_with and
create_bill_with take the optional fields for S4.3 and for tests. They
are the existing issue commands with more fields, not a new state
transition, so no Lean Step is added. Kani: line_amount_is_exact runs in
`just prove`; discount_max_is_bounded relates a product to a quotient and
runs in `prove-slow` (163 s), as the proration charge harnesses do.

just check green; just prove green.
2026-10-27 00:02:51 -06:00

266 lines
13 KiB
Makefile

# wanderbooks repo recipes. `just` lists them.
set shell := ["bash", "-cu"]
# The Netlify CLI, fetched on demand. Its packages' native build scripts
# (sharp, unix-dgram) fail on this machine and deploying needs neither, so
# they are skipped.
netlify := "npm_config_ignore_scripts=true npx --yes netlify-cli@27"
# List recipes
default:
@just --list
# Everything CI should check: format, clippy and rustdoc (zero warnings), tests
check: sweep-test-tmp
cargo fmt --all --check
cargo fmt --manifest-path bookspec/Cargo.toml --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo clippy --manifest-path bookspec/Cargo.toml --workspace --all-targets -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --quiet
RUSTDOCFLAGS="-D warnings" cargo doc --manifest-path bookspec/Cargo.toml --workspace --no-deps --quiet
cargo test --workspace
cargo test --manifest-path bookspec/Cargo.toml --workspace
@just server-only
@just sweep-test-tmp
# The server-only build (plan D-22, for another machine) builds, passes
# clippy and the one executable's own tests, and links no window libraries.
# The books server's tool suites (mcp_journeys, spec_*, oracle_mcp,
# hostile_args, ...: the same binary's server, D-44) run once, in `check`.
server-only:
#!/usr/bin/env bash
set -euo pipefail
cargo clippy -p ledger-cli --no-default-features --all-targets -- -D warnings
cargo test -p ledger-cli --no-default-features --bins --test one_executable --test setup --test local_server --test open_while_held --test backups --test format_upgrade --test startup
tree=$(cargo tree -p ledger-cli --no-default-features -e normal)
if grep -qiE '\b(eframe|egui|winit|wgpu|glow|ledger-gui)\b' <<<"$tree"; then
echo "the server-only build links a window library" >&2
exit 1
fi
echo "server-only build: no window libraries"
# Test books go in the system temp dir: TMPDIR, else /tmp. Every test's dir is
# removed when the test ends, pass or panic (a guard dropped on unwind;
# until 2026-10-17 many were removed only on pass, or never: 117,000 piled
# up). A run killed mid-test (a mutant's timeout, ^C) runs no destructor,
# so this sweep removes this user's test book dirs older than an hour; a
# run in progress elsewhere keeps its own. Such a run can also leave its
# processes behind: a wanderbooks process whose `--root` is a test dir
# (a `wb-*` dir) that no longer exists is ended, woken first (a test may
# have stopped it with SIGSTOP; 2026-10-23, one sat stopped for a day).
# Only such a process: the live books' processes have no test `--root`.
sweep-test-tmp:
#!/usr/bin/env bash
# A day, not an hour: a working session in a parallel worktree keeps its
# test roots (and a running test's data root) for hours, and sweeping
# another engineer's roots mid-run fails their run for no reason.
find "${TMPDIR:-/tmp}" /tmp -maxdepth 1 -user "$(id -u)" -type d -mmin +1440 \( -name 'wb-*' -o -name 'wanderbooks-*' -o -name 'ledger-*' \) -exec rm -rf {} + 2>/dev/null || true
for pid in $(pgrep -u "$(id -u)" -x wanderbooks; pgrep -u "$(id -u)" -f '^wanderbooks (server|host|mcp|serve) '); do
root=$(tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null | sed -n '/^--root$/{n;p;q}')
case "$root" in
*/wb-*/*|*/wb-*) [ -e "$root" ] || { kill -CONT "$pid"; kill -KILL "$pid"; } 2>/dev/null ;;
esac
done
true
# The Kani harnesses `prove` leaves to `prove-slow`: each takes four to
# twenty minutes alone on a busy machine, and with them the suite (about
# 100 minutes of CPU, 2026-10-21) can't finish inside the 30-minute cap at
# four jobs, a CI runner's. Every other harness in src/proofs.rs runs in `prove`, a new one
# included; a name here that isn't a harness fails both (docs/verification.md).
kani_slow := "accounting_equation cumulative_close_never_drifts_a_unit_at_near_thirds cumulative_closing_entry_balances discount_max_is_bounded entry_flows_pure_are_movements entry_flows_sum_to_cash prorate_parts_sum_to_total sums_are_exact the_last_months_charge_is_within_half_a_cent"
# Formal proofs: Kani on the accounting kernel (all but kani_slow), Lean for books of any size (docs/verification.md)
prove: (_kani "fast")
#!/usr/bin/env bash
set -euo pipefail
cd verify/lean
lake build
audit=$(lake env lean Audit.lean)
if grep -v -E "depends on axioms: \[(propext|Quot.sound|Classical.choice)(, (propext|Quot.sound|Classical.choice))*\]$|does not depend on any axioms" <<<"$audit"; then
echo "Lean audit: a theorem rests on something beyond the standard axioms" >&2
exit 1
fi
echo "Lean: $(wc -l <<<"$audit") headline theorems, standard axioms only"
# The slow Kani harnesses (kani_slow), each under its own 30-minute cap, $(nproc) at a time (CI runs it beside prove)
prove-slow: (_kani "slow")
# Every proof: prove, then prove-slow
prove-all: prove prove-slow
# Kani on one set of the harnesses: "fast" (all but kani_slow, the whole
# run under the cap) or "slow" (each harness under the cap)
_kani set:
#!/usr/bin/env bash
set -euo pipefail
# Every harness: the fn after each #[kani::proof] in src/proofs.rs.
all=$(awk '/#\[kani::proof\]/ { p = 1; next } p && match($0, /^fn [a-z_0-9]+/) { print substr($0, 4, RLENGTH - 3); p = 0 }' crates/ledger-core/src/proofs.rs | sort)
slow=$(tr ' ' '\n' <<<"{{kani_slow}}" | sort)
if missing=$(comm -13 <(echo "$all") <(echo "$slow")) && [ -n "$missing" ]; then
echo "kani_slow names no harness: $missing" >&2
exit 1
fi
if [ "{{set}}" = slow ]; then names=$slow; else names=$(comm -23 <(echo "$all") <(echo "$slow")); fi
logs=$(mktemp -d)
# What each run must show: every harness asked for verified, and no
# cover unsatisfiable (a vacuous proof).
checked() { # log, count
if ! grep -qE "^Complete - $2 successfully verified harnesses, 0 failures, $2 total" "$1"; then
echo "Kani: expected $2 harness(es) verified, see $1" >&2
return 1
fi
if grep -E "\*\* [0-9]+ of [0-9]+ cover properties satisfied" "$1" |
awk '{ if ($2 != $4) bad = 1 } END { exit !bad }'; then
echo "Kani: an unsatisfiable cover; some proof is vacuous ($1)" >&2
return 1
fi
}
cd crates/ledger-core
# Capped (timeout 1800, 12 GB each): a harness that blows up should
# fail, not eat the machine.
ulimit -v 12000000
if [ "{{set}}" = fast ]; then
args=()
for h in $names; do args+=(--harness "proofs::$h"); done
timeout 1800 cargo kani -j "$(nproc)" --output-format terse --exact "${args[@]}" | tee "$logs/fast.log"
checked "$logs/fast.log" "$(wc -l <<<"$names")"
else
cargo kani --only-codegen --output-format terse
failed=0
for h in $names; do
while [ "$(jobs -rp | wc -l)" -ge "$(nproc)" ]; do wait -n || failed=1; done
( start=$(date +%s)
timeout 1800 cargo kani --output-format terse --exact --harness "proofs::$h" > "$logs/$h.log" 2>&1 &&
checked "$logs/$h.log" 1 &&
echo "Kani: $h verified in $(( $(date +%s) - start )) s" ||
{ echo "Kani: $h failed or ran past 1800 s ($logs/$h.log)" >&2; exit 1; } ) &
done
while [ "$(jobs -rp | wc -l)" -gt 0 ]; do wait -n || failed=1; done
wait || failed=1
[ "$failed" = 0 ] || exit 1
fi
rm -rf "$logs"
echo "Kani ({{set}}): $(wc -l <<<"$names") harnesses verified"
# Random books through wanderbooks and GnuCash 5.10, compared exactly (Docker)
oracle:
docker build -q -t wanderbooks-gnucash-oracle oracle/gnucash
cargo test -p ledger-core --test oracle_gnucash -- --ignored --nocapture
# Fuzz one target for some seconds (nightly; corpus in fuzz/corpus/<target>)
fuzz target="fuzz_op_sequence" seconds="300":
#!/usr/bin/env bash
set -euo pipefail
# Call the binary directly: mise's shim would pin stable rustc.
fuzz=$(mise which cargo-fuzz)
cd fuzz && mkdir -p corpus/{{target}} seeds/{{target}}
# corpus/ grows (git-ignored); seeds/ holds the checked-in minimal inputs.
RUSTUP_TOOLCHAIN=nightly-2026-10-01 "$fuzz" fuzz run {{target}} corpus/{{target}} seeds/{{target}} -- \
-max_total_time={{seconds}} -timeout=10 -rss_limit_mb=2048
# Undefined-behavior check of the core's tests under Miri (nightly)
miri:
PROPTEST_CASES=4 MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly-2026-10-01 miri test -p ledger-core --lib --test kernel_bridge --test invariants
# Where cargo-mutants copies and builds the tree for each mutant (several
# GB per job): the user's cache on disk, never /tmp, which is often RAM.
# The tests it runs honor the same TMPDIR (every test's books go in
# std::env::temp_dir(), so TMPDIR); until 2026-10-22 a forced
# `[env] TMPDIR = "/tmp"` in .cargo/config.toml kept them in /tmp for
# speed, which also overrode a TMPDIR set for any cargo test.
# A mutant only needs some test to fail, so proptest doesn't shrink.
#
# Every mutation recipe takes a shard, "k/n" (default: all of it), so CI
# can spread a crate over parallel jobs. Test timeouts are three times the
# run's own baseline, never under the floor given: a slower machine gets
# longer ones instead of false timeouts.
mutants_tmp := env_var_or_default("XDG_CACHE_HOME", env_var("HOME") + "/.cache") + "/wanderbooks/mutants"
# Mutation testing of the core: do the tests catch planted bugs?
mutants shard="0/1": sweep-test-tmp
# Core and store tests (the store's replay tests reach Ledger::replay_*);
# the whole workspace would rebuild the viewer for every mutant.
mkdir -p {{mutants_tmp}}
PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-core --exclude crates/ledger-core/src/proofs.rs --test-package ledger-core --test-package ledger-store -j 4 --timeout-multiplier 3 --minimum-test-timeout 120 --shard {{shard}}
@just sweep-test-tmp
# Mutation testing of the store. Fewer random cases per mutant (each needs
# a run that catches it, not the deep sweep).
mutants-store shard="0/1": sweep-test-tmp
mkdir -p {{mutants_tmp}}
STORE_CASES=64 PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-store --test-package ledger-store -j 3 --timeout-multiplier 3 --minimum-test-timeout 300 --shard {{shard}} -o mutants-store
@just sweep-test-tmp
# Mutation testing of the importers and report renderers.
mutants-import shard="0/1": sweep-test-tmp
mkdir -p {{mutants_tmp}}
PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-import --test-package ledger-import -j 4 --timeout-multiplier 3 --minimum-test-timeout 300 --shard {{shard}} -o mutants-import
@just sweep-test-tmp
# Mutation testing of the settings parser and the receipt policy.
mutants-config shard="0/1": sweep-test-tmp
mkdir -p {{mutants_tmp}}
PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-config --test-package ledger-config -j 4 --timeout-multiplier 3 --minimum-test-timeout 120 --shard {{shard}} -o mutants-config
@just sweep-test-tmp
# Mutation testing of the MCP server: every mutant runs its unit tests and
# the suites that drive a real books server (ledger-cli's tests, D-44),
# built without the viewer; fewer random cases each, as for the store.
mutants-mcp shard="0/1": sweep-test-tmp
mkdir -p {{mutants_tmp}}
MCP_CASES=16 PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-mcp --test-package ledger-mcp --test-package ledger-cli --no-default-features -j 4 --timeout-multiplier 3 --minimum-test-timeout 600 --shard {{shard}} -o mutants-mcp
@just sweep-test-tmp
# No mutation runs on the viewer (owner, 2026-10-18: they crushed the
# machine, and for the GUI it's "not as critical"). Its two musts are
# tested directly instead: it never writes the books (tests/read_only.rs)
# and every figure it shows is the engine's (tests/view_model.rs, against
# ledger-core on random books). Mutation recipes for other crates leave
# crates/ledger-gui out.
# Benchmarks of the core's hot paths (no budget gates them yet)
bench:
cargo bench -p ledger-core --bench books
cargo bench -p ledger-store --bench store
# Build the release binaries (`wanderbooks`, the one executable, and the
# viewer's own)
build:
cargo build --release -p ledger-cli -p ledger-gui
# Install the binaries from this checkout into ~/.cargo/bin
install:
for c in ledger-cli ledger-gui; do cargo install --locked --path crates/$c; done
# Open the GUI on the demo company
demo:
cargo run --release -p ledger-gui -- --demo
# Regenerate the website's screenshots (dark) from the demo company
site-shots:
#!/usr/bin/env bash
set -euo pipefail
tmp=$(mktemp -d)
SHOT_DIR="$tmp" SHOT_DARK=1 SHOT_LIVE=1 SHOT_ONLY=overview,pnl,cash cargo test -p ledger-gui --test screens
# The hero's viewer pane: taller, to sit beside the session.
SHOT_DIR="$tmp" SHOT_DARK=1 SHOT_LIVE=1 SHOT_ONLY=entry SHOT_ENTRY=245 SHOT_HEIGHT=1150 cargo test -p ledger-gui --test screens
for f in overview entry pnl cash; do cp "$tmp/$f.png" site/assets/; done
rm -f site/assets/overview-dark.png
rm -rf "$tmp"
# Assemble the website in site/dist (index.html, install.sh, assets)
site:
rm -rf site/dist && mkdir -p site/dist
cp site/index.html install.sh site/dist/
cp -r site/assets site/dist/
# Deploy the website to wanderbooks.ai (Netlify; `netlify login` once first)
deploy: site
{{netlify}} deploy --prod --dir site/dist --no-build
# Deploy a preview of the website and print its URL
deploy-preview: site
{{netlify}} deploy --dir site/dist --no-build