diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a74d16de9..1e8247b3b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -242,7 +242,7 @@ InferNode uses Plan 9's `mk` (not GNU make). Important points: - Build from your host OS terminal, not inside the Inferno emulator - `mkfile` in each directory defines build rules - `mk install` compiles and copies output to `dis/` -- `mk nuke` cleans build artifacts +- `mk emunuke` cleans emulator build artifacts. Root `mk nuke` would delete tracked `dis/`; see `docs/WORKFLOW-TRAPS.md`. - Don't use `&&` to chain commands in mkfiles — use `;` or separate rules ### Code Style diff --git a/build-macos-headless.sh b/build-macos-headless.sh index cb5b6d3f1..caa14d5bf 100755 --- a/build-macos-headless.sh +++ b/build-macos-headless.sh @@ -23,6 +23,15 @@ echo "Building for: SYSHOST=$SYSHOST OBJTYPE=$OBJTYPE" echo "GUI Backend: headless (no display)" echo "" +# mkhost-MacOSX sets NDATE=ndate and resolves it through PATH. BSD date has +# no -n, so without utils/ndate built the emulator compiles KERNDATE from an +# empty string and fails on `ulong kerndate = ;`. The EMUDIRS walk builds it +# before emu; this script goes straight to the emulator, so build it here. +if [[ ! -x "$ROOT/MacOSX/arm64/bin/ndate" ]]; then + echo "Building utils/ndate (needed for KERNDATE)..." + (cd "$ROOT/utils/ndate" && mk install) +fi + # Build emulator cd "$ROOT/emu/MacOSX" diff --git a/build-macos-sdl3.sh b/build-macos-sdl3.sh index 2859db61b..17af39c9e 100755 --- a/build-macos-sdl3.sh +++ b/build-macos-sdl3.sh @@ -47,10 +47,16 @@ echo "Building for: SYSHOST=$SYSHOST OBJTYPE=$OBJTYPE" echo "GUI Backend: SDL3" echo "" -# Stamp build version (matches CI workflow) +# Stamp build version (matches CI workflow). Restore on any exit so a +# failed mk cannot leave the tracked file dirty. Match the +# unstamped form only so a leftover stamp cannot accumulate. BUILD_DATE=$(date +%Y%m%d) SHORT_SHA=$(git -C "$ROOT" rev-parse --short=8 HEAD 2>/dev/null || echo "local") -sed -i '' "s|InferNode 0.1|InferNode 0.1 build ${BUILD_DATE}-${SHORT_SHA}|" "$ROOT/include/version.h" +restore_version_h() { + git -C "$ROOT" checkout -- "$ROOT/include/version.h" 2>/dev/null || true +} +trap restore_version_h EXIT +sed -i '' "s|InferNode 0.1 (|InferNode 0.1 build ${BUILD_DATE}-${SHORT_SHA} (|" "$ROOT/include/version.h" echo "Version: $(grep VERSION "$ROOT/include/version.h")" echo "" @@ -63,9 +69,6 @@ mk clean 2>/dev/null || true echo "Building SDL3 GUI emulator..." mk GUIBACK=sdl3 -# Restore version.h so repeated builds don't accumulate stamps -git -C "$ROOT" checkout -- "$ROOT/include/version.h" 2>/dev/null || true - if [[ -f o.emu ]]; then echo "" echo "=== Build Successful ===" diff --git a/docs/DOCUMENTATION-INDEX.md b/docs/DOCUMENTATION-INDEX.md index 0a26589a1..e904738e5 100644 --- a/docs/DOCUMENTATION-INDEX.md +++ b/docs/DOCUMENTATION-INDEX.md @@ -133,3 +133,10 @@ Pool quanta must be 127 for 64-bit (not 31 as for 32-bit). This single change in - [inferno64](https://github.com/caerwynj/inferno64) - Reference 64-bit port - [Inferno Shell paper](https://www.vitanuova.com/inferno/papers/sh.html) - [EMU manual](https://vitanuova.com/inferno/man/1/emu.html) + +## Testing and Workflow + +| Document | Description | +|----------|-------------| +| [WORKFLOW-TRAPS.md](WORKFLOW-TRAPS.md) | Non-obvious ways this repository wastes an afternoon: root `mk nuke`, veltro build collateral, unlisted tests, and stale test bytecode | + diff --git a/docs/WORKFLOW-TRAPS.md b/docs/WORKFLOW-TRAPS.md new file mode 100644 index 000000000..aef69caae --- /dev/null +++ b/docs/WORKFLOW-TRAPS.md @@ -0,0 +1,43 @@ +# Workflow traps + +Non-obvious ways this repository wastes an afternoon. Each of these has +cost one at least once. `CONTRIBUTING.md` covers the rules; this covers +the surprises. + +## Building + +**`mk nuke` at the repo root deletes the tracked `dis/` runtime tree.** +Root nuke walks `$DIRS` (`$EMUDIRS` + `appl`). appl's nuke deletes +`$DISBIN`, and for appl that is committed `dis/` — about 900 `.dis` +files a fresh clone needs in order to boot. Afterward the emulator dies +with `panic: loading "/dis/emuinit.dis": ... does not exist`, and +`git status` shows a wall of deletions that look like a bad merge. +Nothing is permanently lost: `git checkout -- .` restores the tree. +`mk emunuke` walks `$EMUDIRS` only and is the safe emulator-only clean. +On Posix, root `mk nuke` now refuses unless `NUKE_DIS=1` is set. +`cd appl && mk nuke` is unguarded and still deletes `dis/`. + +**A failed macOS build can leave `include/version.h` rewritten.** +`build-macos-sdl3.sh` stamps the version before compiling; if the build +then fails, the stamped file stays behind and shows up as an unrelated +modification in the next `git status`. The script now restores it on +failure. + +## Building Limbo + +**`mk install` in `appl/veltro` rebuilds every veltro module.** The +tracked `dis/veltro/*.dis` were built by a different limbo build, so +unrelated `.dis` files show up modified. `appl/cmd` and `tests` support +per-target `mk .install`; `appl/veltro` does not, so restore the +unrelated ones with `git checkout` afterwards. + +## Testing + +**Stale test bytecode fails as though the code were broken.** The +`post-merge` hook does not reliably rebuild `dis/tests/*.dis`, and there +is no `link typecheck` error to warn you when no `.m` interface changed: +a test's expectations are compiled into its bytecode, so a binary built +before a rename still asserts the old name while the source asserts the +new one. The failure names the assertion, not the staleness, which sends +you into the wrong file. `run-tests.sh` now refuses to run when any +`dis/tests/*.dis` is older than its source. diff --git a/lib/guide b/lib/guide index ed7c351c2..e8dc6c9b6 100644 --- a/lib/guide +++ b/lib/guide @@ -312,7 +312,7 @@ ls /mnt/llm cat /mnt/llm/model # Speech interface -ls /n/speech +ls /mnt/speech # Xenith filesystem ls /mnt/xenith @@ -372,8 +372,8 @@ cd appl/xenith && mk install # Build a Veltro tool cd appl/veltro/tools && limbo -I$ROOT/module -I$ROOT/appl/veltro -gw -o $ROOT/dis/veltro/tools/toolname.dis toolname.b -# Clean rebuild -mk nuke && mk install +# Clean rebuild (emulator only; root mk nuke deletes tracked dis/) +mk emunuke && mk emuinstall # IMPORTANT: use native tools (MacOSX/arm64/bin/), not hosted limbo diff --git a/mkfile b/mkfile index 25f4b92c0..abcaaa0ad 100644 --- a/mkfile +++ b/mkfile @@ -56,6 +56,20 @@ nukedist:V: nuke rm -f $ROOT/$OBJDIR/lib/lib*.a &-Posix:QV: + # appl nuke deletes the tracked dis/ runtime tree. + if [ "$stem" = nuke ] + then + if git -C $ROOT ls-files --error-unmatch dis/emuinit.dis >/dev/null 2>&1 + then + if [ -z "$NUKE_DIS" ] + then + echo 'refusing mk nuke: would delete tracked dis/' >&2 + echo 'use: mk emunuke # emulator-only clean' >&2 + echo ' NUKE_DIS=1 mk nuke # really delete dis/' >&2 + exit 1 + fi + fi + fi for j in $DIRS utils do echo "(cd $j; mk $MKFLAGS $stem)" diff --git a/run-tests.sh b/run-tests.sh index bc48ca9db..f751a5b76 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -167,6 +167,28 @@ run_internal_tests() { return 1 fi + # Refuse to run test bytecode that is older than its source. A stale + # .dis carries the assertions it was compiled with, so it fails against + # code that is correct, and no link typecheck error explains why unless + # a .m interface also changed. That reads as "the change is broken" and + # invites editing correct code to match a stale test. A merge is the + # usual way a tree ends up like this. + _stale="" + for _src in "$ROOT"/tests/*_test.b; do + [ -f "$_src" ] || continue + _dis="$ROOT/dis/tests/$(basename "$_src" .b).dis" + [ -f "$_dis" ] || continue + if [ "$_src" -nt "$_dis" ]; then + _stale="$_stale $(basename "$_dis")" + fi + done + if [ -n "$_stale" ]; then + echo "stale test bytecode, older than its source:" + for _s in $_stale; do echo " dis/tests/$_s"; done + echo "Run 'mk install' in tests/ before trusting a result." + return 1 + fi + # Build emu args EMU_ARGS="-r$ROOT" if [ "$VERBOSE" = "1" ]; then diff --git a/tools/verify-dis-paths.sh b/tools/verify-dis-paths.sh index 969814d7c..cb285aeb1 100755 --- a/tools/verify-dis-paths.sh +++ b/tools/verify-dis-paths.sh @@ -11,11 +11,24 @@ # correct path silently kept running while every "rebuilt" file landed # in a parallel directory emu never read from. # -# This script verifies that for every Limbo source file in -# appl/cmd/ that contains a `PATH: con "/dis/...` declaration, the +# This script verifies that for every Limbo source file that contains a +# `PATH: con "/dis/...` declaration for the module it IMPLEMENTS, the # corresponding compiled .dis exists at that path AND is at least as -# new as the source. Runs in under a second. Pre-commit hook -# candidate; also wired into `make` (see mkfile). +# new as the source. +# +# Coverage: every directory that installs .dis files, i.e. every .b +# source under appl/ and tests/. The set is derived from the source +# tree itself (find), NOT from a hardcoded directory list: a hardcoded +# list is this same bug one directory later. Parsing the mkfiles +# instead would re-implement mk's recursive DIRS/include logic badly; +# the whole-tree scan is a strict superset of the mkfile-derived set, +# and sources that legitimately carry no inline PATH constant (their +# load path lives in module/*.m; mk's TARG governs their target) are +# counted and skipped explicitly, not failed. +# +# Runs in well under a second (single awk pass over the tree; stat is +# only spawned for the few sources that declare a PATH). Pre-commit +# hook candidate; also wired into CI (see .github/workflows/). # # Exit codes: # 0 — all sources have a fresh, correctly-placed .dis @@ -29,6 +42,7 @@ cd "$ROOT" fail=0 checked=0 +skipped=0 # stat(1) is the classic cross-platform divergence: # macOS / BSD: stat -f %m FILE → mtime as epoch seconds @@ -42,42 +56,81 @@ else mtime() { stat -f %m "$1"; } fi -scan_module_path() { - # Return the PATH constant of the module that this .b file - # IMPLEMENTS (top-level `implement Foo;`), not the first PATH it - # happens to mention. Files commonly cite the PATHs of modules - # they LOAD (cowfs, etc.) before declaring their own interface; - # matching the first PATH alone gives wrong answers. - # - # Strategy: find `implement Foo;`, then scan forward for - # `Foo: module {` ... `PATH: con "/dis/..."` ... `}`. +# scan_module_paths — one awk process over ALL sources. +# Same matching rules as tools/compile-limbo.sh (kept in sync): find +# `implement Foo;`, then scan forward for `Foo: module {` ... +# `PATH: con "/dis/..."` ... `}`. The PATH of the module the file +# IMPLEMENTS, not the first PATH it mentions — files commonly cite the +# PATHs of modules they LOAD (cowfs, etc.) before their own interface. +# +# Input: the .b sources as file arguments. +# Output: "\t" per source that declares one, +# plus a final "#skipped " line for sources that do not. +scan_module_paths() { awk ' - /^implement[[:space:]]+[A-Za-z][A-Za-z0-9_]*[[:space:]]*;/ { + function reset() { + impl = ""; in_mod = 0; done = 0 + } + FILENAME != prev { + if (prev != "" && !done) + noskipped++ + prev = FILENAME + reset() + } + !done && /^implement[[:space:]]+[A-Za-z][A-Za-z0-9_]*[[:space:]]*;/ { match($0, /[A-Za-z][A-Za-z0-9_]*[[:space:]]*;/) impl = substr($0, RSTART, RLENGTH) sub(/[[:space:]]*;.*$/, "", impl) } - impl != "" && $0 ~ ("^" impl "[[:space:]]*:[[:space:]]*module") { + !done && impl != "" && $0 ~ ("^" impl "[[:space:]]*:[[:space:]]*module") { in_mod = 1 next } - in_mod && /PATH[[:space:]]*:[[:space:]]*con[[:space:]]*"\/dis\// { + in_mod && !done && /PATH[[:space:]]*:[[:space:]]*con[[:space:]]*"\/dis\// { match($0, /"\/dis\/[^"]+"/) - if(RSTART > 0) { + if (RSTART > 0) { p = substr($0, RSTART+1, RLENGTH-2) - print p - exit + print FILENAME "\t" p + done = 1 } } in_mod && /^[[:space:]]*}/ { in_mod = 0 } - ' "$1" + END { + if (prev != "" && !done) + noskipped++ + print "#skipped " noskipped + 0 + } + ' "$@" } -for src in appl/cmd/*.b; do - disrel=$(scan_module_path "$src" || true) - [[ -z "$disrel" ]] && continue +# Every tree that installs .dis files: appl/ (via appl/*/mkfiles) and +# tests/ (via tests/mkfile). find-derived so a new directory is +# covered the moment it exists. bash 3.2 (macOS /bin/bash) has no +# mapfile, so build arrays with read loops. +SOURCES=() +while IFS= read -r -d '' src; do + SOURCES+=("$src") +done < <(find appl tests -name '*.b' -type f -print0 | sort -z) + +if (( ${#SOURCES[@]} == 0 )); then + echo "verify-dis-paths: no .b sources found — run from the repo root" >&2 + exit 2 +fi + +ROWS=() +while IFS= read -r row; do + ROWS+=("$row") +done < <(scan_module_paths "${SOURCES[@]}") + +for row in "${ROWS[@]}"; do + if [[ "$row" == \#skipped* ]]; then + skipped="${row#\#skipped }" + continue + fi + src="${row%%$'\t'*}" + disrel="${row#*$'\t'}" # Strip leading slash and prefix with the source-tree root. dispath="${disrel#/}" @@ -93,16 +146,18 @@ for src in appl/cmd/*.b; do if (( src_t > dis_t )); then echo "FAIL: $src is newer than $dispath (recompile needed)" >&2 fail=1 + continue fi checked=$((checked + 1)) done if (( fail )); then echo "" >&2 - echo "$checked sources checked; FAILURES above." >&2 - echo "Recompile with: limbo -I module -o " >&2 + echo "$checked sources checked, $skipped skipped (no inline PATH); FAILURES above." >&2 + echo "Recompile with: tools/compile-limbo.sh (do NOT pick an -o path by hand)" >&2 echo "(do NOT default to dis/cmd/X.dis — read the module's PATH constant)" >&2 exit 1 fi -echo "OK: $checked sources have fresh .dis at their declared PATH" +echo "OK: $checked sources have fresh .dis at their declared PATH" \ + "($skipped skipped: no inline PATH constant, mk TARG governs those)"