x-lang

Contributing

Build Prerequisites

engine — where x-lang looks for its engine

x-lang reaches its engine through one path: engine, a symlink in the repo root that make points at whatever engine this tree builds against. Everything downstream uses that one spelling — the boot’s contract includes, the JIT’s -I flags, the gates, the conformance runner — so nothing else has to know which engine it got.

make                                  # build against the engine already linked
make X_ENGINE_DIR=../my-engine        # links engine -> ../my-engine
make engine                           # fetch the release the pin names
make engine-source                    # clone that release and build it here

make engine reads tools/engine/engine.pin.xon — which implementation, which release, and a URL plus sha256 per platform — fetches the artifact for this machine, verifies it, unpacks it under deps/engine/ and links it. Three rules are worth knowing before you rely on it:

A platform the pin declares no artifact for builds from source, and says so. The pin ships rows for darwin/arm64 and linux/x86-64 — the two platforms x-engine-c publishes — so everything else (the Pi, any 32-bit target) takes the source arm and always will until someone publishes for it.

A fetched engine runs the whole gate. make gates and make test pass against a release artifact, which is the point of the arrangement: the tree does not need the engine’s C to hold the language to its contract.

Four targets have no subject in an artifact — check-isa, check-obj-layout, check-base-paths and test-c ask whether the engine’s C agrees with what it publishes, and a release ships the publications and no C. They announce that they skipped, on every run, and name what still covers the ground: the engine’s own repository ratchets those three on every build of itself, and check-compliance here holds the digests its declaration states against the manifests shipped beside it. A gate that goes quiet is indistinguishable from a gate that passed.

check-prim-coverage does not skip. It reads the engine’s isa.x — the manifest the engine ratchets against its own C — instead of scanning the C itself, so it asks the same question of a checkout and a release alike.

The variant builds (make test-asan, x-bin-cov) do need a checkout, and say so rather than skipping: running no sanitizer is not a result.

make engine-source                    # a checkout, built here
make X_ENGINE_DIR=/path/to/checkout   # a checkout you already have

make check-engine-fetch drives the whole acquisition path over file:// against a fixture — verify, reuse, tamper, a declared artifact that will not fetch, padded columns, an unreadable row, an unknown form — so the machinery is gated rather than exercised once per machine and never again.

An engine directory is either a checkout (has a Makefile; make builds it) or an unpacked release (ships a built binary; nothing to build). Both are the same subject at different paths, which is why a second implementation needs no edit to lib/.

The link, once pointed somewhere explicitly, stays there: a plain make does not quietly reset it. It is .gitignored — it describes one working tree’s choice, not the project’s, and a tree that has never acquired an engine has nothing to fall back to and says so.

make clean && make

Code Style

C Code

There is no C in this repository. The engine’s C conventions — C89, declarations at the top of the function, the x_ prefix, the accessor families, no globals, stack-allocated pairs, the GC rooting discipline and the Doxygen house style — live with the code they govern, in x-engine-c.

x-lang Code

Method Naming (adjudicated — one name per concept)

Testing

Test Structure

Tests are markdown spec files in tests/x/specs/ organized by category:

Running Tests

make test-x          # x-lang spec suite, booted from state images (IMG=0: from source)
make test-c          # C unit tests
make test            # all tests (the full gate)
make test-asan       # both suites under AddressSanitizer (memory-safety net)

Adding Tests

Tests use a markdown format where each ### heading is a test:

## section-name

### test description

\`\`\`scheme
(expression)
\`\`\`
---
    expected output

The spec runner evaluates the scheme code block and compares stdout against the indented expected output after the --- separator.

Last line only (default). By default the runner compares only the last non-empty stdout line, and stderr is discarded. To assert a single multi-value result this way, put it on one line (e.g. (display a)(display " ")(display b)).

Multi-line output (output ).** Fence the expected block as output to compare the **full multi-line stdout instead — for formatters, pretty-printers, and any multi-line render. Leading blank lines are ignored and the trailing newline is trimmed; interior blank lines are significant. Errors are catchable too: the runner prints an uncaught error to stdout as Error: <value>, so a output block can assert it (or use raised/throws?). See tests/x/specs/meta/multiline.spec.md`.

A spec can swap in a custom support library with a # @lib ../tests/x/lib/NAME.x header — it replaces the default lib, so the support file must (include "lib/x-core.x") first (see tests/x/lib/token.x).

The test-with-fix rule

Every bug fix ships with a regression test in the same commit, written so it fails before the fix and passes after — confirm both. A fix without a test that proves it is incomplete: nothing stops the bug from returning. This is the project’s main defense against recurring “should-have-been-caught” regressions. A fix: commit that touches no tests/ file is the smell to avoid.

Error-path assertions

tests/x/lib/assert.x names the “this must raise” pattern, so the silent-failure class (a form that should raise but returns nil) can’t read as a pass. Add # @lib ../tests/x/lib/assert.x to a spec, then:

Memory safety (AddressSanitizer)

make test-asan runs both suites against an ASan build. It catches the crash class that is silently wrong on 64-bit but faults on 32-bit/Pi (e.g. an unchecked read past an object) — run it before pushing C or eval-core changes. The baseline is clean (since 2026-07-13) and CI hard-gates it on merges to main: a red ASan run is a real regression. Note the pinned ASAN_OPTIONS in the Makefile — leak detection is off (the GC does not free at exit), so is the fake stack (incompatible with stack-copying call/cc), and the quarantine is 2G: a tower boot frees more than ASan’s default 256M holds, after which a use-after-free reads a recycled cell and reports nothing.

make check-asan-boot is the cheap, pre-push half of the same idea: it boots every dialect cold on an ASan build of the pinned engine’s sources (~1 min the first time, under a minute after) and fails on any sanitizer report. It exists because the engine has no auto-GC and a precise collector — an object referenced only from a C frame is garbage the moment anything collects, and whether the freed cell is reused before it is read depends on the allocator: glibc reuses it at once, macOS mostly does not. So the whole class was invisible on the desk and red in CI (#614). ASan makes the allocator irrelevant. It rides test-fast (the pre-push hook) and gates.

Pre-push gate

make install-hooks   # sets core.hooksPath=.githooks

The hook hard-gates on make test-fast (the fast contract gates, the ASan boot, both suites) and blocks the push if it fails (bypass a single push with git push --no-verify). make test-asan is green at HEAD but slow (~2-3x), so locally it stays opt-in — RUN_ASAN=1 runs it non-blocking; CI hard-gates it on every push regardless.

CI

GitHub Actions (.github/workflows/ci.yml) hard-gates every push and pull request on make test (macOS + Linux) and make test-asan (Linux). The pre-push hook remains the first line of defence: it catches a red suite before it leaves the machine.

Releases

Pushing a version tag (v*) runs .github/workflows/release.yml: the full gate first (a tag on a red tree publishes nothing), then make boot and tools/release/release-manifest.sh, publishing a GitHub Release carrying the amalgamated boot entries (build/boot/*.x, discovered — never a hand list), SHASUMS (coreutils format), and pin.release.xon — the machine-readable manifest (xon) with each file’s sha256, the ISA fingerprint (the digest of engine/tools/contract/isa.x, the C-surface contract the amalgams were built against; make check-isa holds manifest == binary) and the payload fingerprint (one digest over lib, apps and boot, from tools/release/payload-digest.sh). The ISA fingerprint is a compatibility key and cannot serve as an identity one: the C surface is fixed by design, so it is identical across releases. The payload fingerprint and the tag are what distinguish them, and the tag is what the wrapper enforces when booting a pinned amalgam. The workflow also cross-checks the pure-x Sha256 against coreutils on that fingerprint, and make check-release-manifest gates the manifest script on every test run so it cannot rot between releases.

The same tag also builds a relocatable per-platform binary tarball (tools/release/package.sh, gated by make check-package) and uploads it with a .sha256 sidecar. Each tarball’s install tree is stamped with the tag (share/x/contract/release — the library’s stamp, distinct from the engine’s own x-release) and the payload fingerprint; package.sh fails the job unless its stamp matches both the tree it shipped and the repo the release manifest was computed from.

macOS notarization (opt-in via repository secrets)

The macOS tarball’s engine is Developer ID signed and notarized when the signing secrets are present, and falls back to ad-hoc signing (the xattr step) when they are absent — so the release pipeline works with or without them. Signing uses the hardened runtime; the JIT survives it because entitlements.plist already declares allow-jit / allow-unsigned-executable-memory. A .tar.gz can’t carry a stapled ticket, so none is stapled — Gatekeeper verifies the signed binary online on first run.

To enable it, create these five repository secrets (Settings → Secrets and variables → Actions, or gh secret set NAME). The values never leave your machine except into GitHub’s secret store:

Secret What it is How to get it
MACOS_CERT_P12 base64 of a Developer ID Application cert + private key (.p12) Create the cert in Xcode (Settings → Apple Accounts → select team → Manage Certificates… → +; needs the paid Developer Program, Account Holder role) or at developer.apple.com; export it as .p12 from Keychain Access — which still exists beside the Passwords app, at /System/Library/CoreServices/Applications/Keychain Access.app (My Certificates → right-click → Export…); then base64 -i cert.p12 \| pbcopy
MACOS_CERT_PASSWORD the password you set on that .p12 export
MACOS_NOTARY_KEY_P8 base64 of an App Store Connect API key (.p8) appstoreconnect.apple.com → Users and Access → Integrations → App Store Connect API → generate a key; base64 -i AuthKey_XXX.p8 \| pbcopy
MACOS_NOTARY_KEY_ID that key’s Key ID shown beside the key
MACOS_NOTARY_ISSUER_ID that key’s Issuer ID shown above the keys list

The workflow imports the cert into a throwaway keychain, derives the identity, signs + submits via notarytool, and tears the keychain down on exit. The signing path runs only on a real tag with the secrets set; prove it with a throwaway v*-rc tag and confirm a downloaded tarball runs without the xattr step.

Commit Conventions

This project follows AngularJS commit conventions:

<type>(<scope>): <subject>

Types: feat, fix, docs, style, refactor, test, chore

Only feat and fix appear in changelogs.

Documentation

Generating Docs

make doc-c           # C API reference (Doxygen → docs/ref/c/)
make doc-x           # x-lang library reference (doc-gen → docs/ref/x/)
make doc             # both

Adding Library Documentation

Wrap function definitions in (doc ...):

(doc (def my-function
  (fn (_ x y)
    (+ x y)))
  (param x INT "First operand")
  (param y INT "Second operand")
  (returns INT "Sum of x and y")
  "Add two integers.")

The (doc ...) form is transparent — it evaluates the inner def normally, then registers metadata for the doc generator.

License

MIT No Attribution (MIT-0)