sh) for test runnersmake engine fetches the
release tools/engine/engine.pin.xon names for your platform and verifies
it. It arrives builtmake engine-source, the sanitizer and coverage builds, and the JIT at
runtime. Not for a fetched engine, and not for the gateengine — where x-lang looks for its enginex-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:
<dest>.rejected — the bytes are the evidence.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
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.
(import ...), exports via (provide ...) at file bottom(include "lib/...")
outside the boot closure resolves against the process cwd, so it works in a
repo checkout and breaks only installed trees — the one environment CI
never runs. Load siblings via (import x/...) (root-resolved) or
./-relative include-once (file-relative). Machine-checked by
make check-path-literals(doc ...) forms with (param ...), (returns ...), description stringexample executes; sample illustrates (#16) — (example "in" "out")
is an executable contract: out must be the true echo, and make doctest
runs every example as a regression test (gate + CI). Side-effectful,
environment-dependent, or prose-described demonstrations are
(sample "in" "prose") — rendered by help exactly like an example, never
executedcond/convert in tokenizer callbacks — Use nested if and direct C primitives to avoid GC corruption'x in all post-boot code (#45 R2). (lit x) is the
boot-layer mechanism spelling, used only in files that parse before the
quote reader exists: x-core.x, its includes through lit-reader.x, and
the files those pull in via mid-boot import (codec/utf8.x,
platform/syscall.x) — plus engine/tools/contract/isa.x, a data manifest. Strings and
comments inside those files may still show 'x. See syntax.md.xmake constructs, new initializes members — two
different operations, one name each. make is THE public constructor
(positional/sizing args: (Dict make 64), (Vector make n fill));
new is the class system’s member-init record door ((new Point x 1 y 2)).
Never alias one to the other, and never ship a method documented “don’t
call me”: a stateful container whose internals new cannot build guards
at the point of harm — first USE of the uninitialized instance raises a
tag 'state Err naming make / from-* (Dict’s %slot, Set’s %d,
Array’s %live). Input-shape constructors are from-x (one name per shape:
from-alist / from-plist / from-bindings / from-list), variadic
literals are of.ref on every class (List ref, Vector ref, Str8 ref,
Gen ref, Obj ref, Ptr ref). Str8 index survives as a documented alias;
don’t add new nth/index methods.length is the property; count is the action (see the glossary).
Every finite collection exposes length — List, Vector, Array, Str8, StrUtf8,
Seq, Dict, Set (Dict/Set store it, O(1)). count names genuine tallying acts
only: Gen count (consumes the stream — a lazy stream has no length
property), Seq count (the cursor-walk the default length delegates to),
Heap count (walks the heap chain), and the verb-compounds count-if
(List), match-count (Regex), count-from (Gen). Never add a count that
merely reads a size — that’s a length.any? / all? / none?; iteration for side effects is
for-each. (Not every?, not each.)->x / from-x as class methods (Dict ->alist,
Hash ->hex, Vector ->list / from-list). The bare X->Y globals
(list->str, str->number) are the pre-class boot layer only — don’t add new ones.(key . val) pair; an alist is a list of assocs; a plist is the
flat (k v k v ...) shape, legal ONLY in option stores (the %opt-cell
family: let-opts, Assoc opt-get-or/opt-get-or-else, new, new-from);
a bindings list is ((key value) ...) two-element lists, the let shape.
The word “pairs” appears in NO method name — pairing producers (List zip,
Gen zip/enumerate, List group-by) emit alists; the converters are
Dict from-alist/->alist and Assoc from-bindings/->bindings. Equality:
the alist layer (Assoc get, assoc-get) compares keys with eq?;
Assoc find (equal?) and Assoc entry (eq?) return the assoc itself and
are the presence-unambiguous entry doors.Dict set!/del!
mutate in place; Assoc put/del return new alists. Same data shape, opposite
update models – the suffix tells you which you are holding.has?
(Dict, Set, Assoc, Pact); positional searches answer includes? (List
element, Str8/StrUtf8 substring). contains? is retired.parse (Json, Xon);
read stays the port-consumption verb (Io read, File read). Byte<->text
transcoders pair encode/decode (Base64, Hex).(List repeat n x), (Str8 repeat n s),
(Str8 make k ch), (Vector make n fill).(Dict make 32), (Array make 32),
(Str8 make k ch) — a constructor’s optional tail rides (. opt)
positionally. Option STORES (alist-or-plist) are for named config only
(let-opts, new/new-from); don’t mix the two styles in one signature.Dict put!, Array set!, Set add!); raw-tier bangs
return () per the C side-effect rule (Obj set!, Ptr set!); removers
return the removed element (Array pop!). Crossing tiers? Check which
one you’re on before chaining.raised’s %no-raise (test
layer only — distinguishes a raised nil from no-raise) and OS-domain
-1 (boundary vocabulary, like JSON’s null symbol). Everything else
misses with nil behind a presence door.insert at ≥ length appends;
update/adjust past the end are no-ops; remove clamps — the same
clamp discipline as take/drop/slice. (Element access — ref —
errors instead; reading a hole is a bug, editing past the end is a no-op.)(Num min a b c) vs (List min lst) — the same split as
+ vs fold. Not drift; don’t “unify” them.Convert to (val target
. args) reads value→target (the conversion dispatcher’s natural order),
and Regex methods are subject-last on the COMPILED REGEX — (rx match
str) ⇒ (Regex match str rx); the string is an argument, the regex is
the subject.(store get k) (Dict, Set — instance dispatch); value
classes speak (Class get key store) (Assoc — data-last static);
registries speak (Class get name) (Pact — module-state singleton).
Every get pairs with eager get-or (default first) and lazy
get-or-else (thunk first).map/evolve rewrite values (keys preserved) so their fn
receives the VALUE; filter/pick/omit/for-each decide on or consume
entries so their fn receives the whole (key . val) assoc. No key+value
two-arg form exists; don’t add one without a driving use.update (n, new
value) / adjust (n, function) / evolve (per-key function alist);
membership: has? (keyed presence) / includes? (element of a sequence)
/ contains? (subsequence of a string). Never add a fourth spelling
(member?, nth-set, …).slice always means (start,
end-exclusive); sub always means (start, length) — on every class
(List slice/sub, Str8/StrUtf8 slice/sub; substring is the
byte-level slice-convention primitive). Never add a range method whose
name doesn’t declare its convention.make = build from parts/config
(Dict make, Gen make step state); of = variadic literal, on every
element container (List/Vector/Array/Set/Gen of ...; Dict excluded —
flat values can’t spell pairs; strings’ variadic literal is str);
from-X = conversion from another shape (from-list, from-alist,
from-bindings, from-seq); build = generate elements by function
(Vector build n f); new/new-from = allocate an instance over
something (object system; Iter new v boxes a value into a cursor).
C side: x_make_X(base, flags, ...) is the flag-taking function,
x_mkX(...) its default-flags macro — a ladder, not duplication.def-class members, (member 'name), set-member!,
own-members) — the only word user-facing docs use; a field is a
named leaf of the base tree (field cells, x_base_field_* /
x_eval_field_*); a slot is a raw object position (Obj ref, type
slots, the vector’s backing slots). Same ladder as the storage tiers.% sigil means private, in four flavors (all legitimate): a
module-private helper (%opt-cell), a cached raw C prim behind a class
method (%str-append, the prim-caching pattern), a macro-expansion
runtime hook referenced from op expansions (%opts), and type-system
plumbing (%make-type, %class-call-handler). The sigil promises
“not API”; it does not say which flavor — the defining comment should.(param ...)/(returns ...):
INT (not INTEGER), BOOL (not BOOLEAN), CALLABLE (not FUNCTION), plus
ANY STRING SYMBOL LIST PAIR CHAR NUMBER VECTOR REGEX FLOAT BIGINT RATIONAL
COMPLEX ITER OBJECT CLASS PTR BUF. PROCEDURE/OPERATIVE are reserved for
the fn/op constructors’ returns. Class names (Dict, Array, Random, …)
are legitimate returns types as-is. make check-doc-vocab enforces the
banned aliases.#f} only; predicates answer
#t/#f; misses return nil (never #f) — index-search misses included;
nil-storable slots need a presence door (has? / presence-based -or),
never a value sentinel; boundaries carry foreign null as the symbol null
(and OS-domain tables keep the OS’s own -1 invalid marker).List ref, Vector, Array, Str8/StrUtf8 ref, the bare
(s i); Gen ref excepted — a lazy stream has no end). Index-search
misses return () (the old -1 exception is repealed).eq?;
anything else converts (a float truncates per the tower’s converter), and
only an UNCONVERTIBLE value errors (“… not convertible to INT”) — which is
how a piped nil miss fails loudly. Coercion runs ONCE per public entry;
self-recursive walks live in inner go fns so loops never re-probe.
Explicit control: pre-convert ((Convert to x %int)) or test with
(Num int? x). Exception: the bare (s i) boot door stays INT-only —
it can run under reader constraints where conversion dispatch is illegal.(step state) -> (value . next-state) or () —
and an iterator is a generator boxed with a cursor cell; Iter next owns
the write-back, steps never mutate. Gen is the one lazy-pipeline class.
Dispatch rule: def-class instances speak message-send; raw typed values get
static data-last methods (fluent via value-call). Counted-vs-infinite rule:
strict classes take counts (List repeat n x, List iterate f n x); lazy
streams are infinite and bounded with take (Gen repeat x, Gen iterate f x).("a,b" split ",") → (Str split "," "a,b").
Deliberate exception: the File/Sys OS layer mirrors POSIX and stays
handle-first ((File write fd data)); fds are ints and never value-dispatch.Tests are markdown spec files in tests/x/specs/ organized by category:
core/ — Language fundamentals (evaluation, forms, closures, logic, arithmetic, strings, etc.)applicative/ — Higher-order function testsext/ — Extension types (bigint, float, rational, complex, decimal, regex, compile, POSIX)lib/ — Standard library functionse2e/ — End-to-end integration teststools/ — Tool tests (lint, fmt)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)
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 asoutputto 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 asError: <value>, so aoutputblock can assert it (or useraised/throws?). Seetests/x/specs/meta/multiline.spec.md`.A spec can swap in a custom support library with a
# @lib ../tests/x/lib/NAME.xheader — it replaces the default lib, so the support file must(include "lib/x-core.x")first (seetests/x/lib/token.x).
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.
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:
(throws? (fn (_) EXPR)) → #t if EXPR raises, else #f(raised (fn (_) EXPR)) → the value EXPR raised, or the symbol %no-raisemake 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.
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.
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.
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.
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.
This project follows AngularJS commit conventions:
<type>(<scope>): <subject>
Types: feat, fix, docs, style, refactor, test, chore
Only feat and fix appear in changelogs.
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
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.
MIT No Attribution (MIT-0)