A general-purpose programming language that shows its work.
Ask what a program can do to the outside world, and Planes computes the answer without running it — every network call, file write and clock read, with its destination. Ask where a value came from, and it traces back to the boundary it entered through.
No other general-purpose language answers either question. Planes answers both, and neither answer is trusted: three independent implementations must agree, and one of them is written in Planes.
$ python3 planes.py -e 'x = 5; y = 3; z = x + y; why z'
8 from x (5) + y (3)
$ python3 shapes_cli.py hn.planes
effect surface of hn.planes
network:
ask https://hacker-news.firebaseio.com/v0/item/{...}.json (computed)
ask https://hacker-news.firebaseio.com/v0/topstories.json
file:
write results.json
console:
show found {...} (computed)
show {...} (computed)
Not a single network call was made to produce that surface.
| Run it | the three implementations, and a browser |
| Three implementations | what self-hosting buys, and what "they agree" means |
| Performance | four ordinary jobs, measured on one named Mac |
| The vocabulary | 32 words, 13 builtins, 8 effect kinds |
| The language | syntax, in one page |
| Numbers are exact | rationals, not floats |
| Effect surface | what a program can do |
| Why | where a value came from |
| Rules | governance as a plane over the surface |
| Annotations | because and note, provably inert |
| Errors name the fix | a commitment, counted |
| Modules | flat names, reported collisions |
| Foreign functions | the FFI, and why doing is a claim |
| The host | seven methods, and nothing else |
| Machine-readable surfaces | the grammar as data |
| Layout | what is where |
| The gate | how it is checked |
python3 planes.py ordinary.planes # run a program
python3 planes.py money.planes --effects # ...and log what it did
python3 planes.py hn.planes --why avg # ...and explain a value
python3 shapes_cli.py hn.planes # what it CAN do, without running
python3 shapes_cli.py hn.planes --json # machine-readable
python3 shapes_cli.py --diff demo/v1.planes demo/v2.planes
python3 shapes_cli.py demo/rules/violation.planes --rules
node js/cli.mjs run ordinary.planes # the JavaScript implementation
node js/cli.mjs meta run ordinary.planes # ...running the SELF-HOSTED one
open index.html # run and analyse in a browser
open paint.html # ...or paint a program's `show` output onto a canvas
bash scripts/ci.sh # the gateBoth pages are also hosted, with no install: https://rmichaelthomas.github.io/planes/ and https://rmichaelthomas.github.io/planes/paint.html.
The gate needs ruff and mypy, which live in ./.venv. It tells you so at
step one if they are missing.
Anyone writing Planes for another project can check a file with Node or Swift
alone. Both parse it and print its effect surface in the --json format
(docs/surface-format-v3.md), and add rule
results with --rules:
node js/cli.mjs shapes manifest.planes # Node, nothing to build
node js/cli.mjs shapes manifest.planes --rules
swift build --package-path swift -c release # Swift, built once
swift/.build/release/planes-swift shapes manifest.planesA file that doesn't parse is refused with the line and what was expected, and a non-zero exit:
$ node js/cli.mjs shapes manifest.planes
syntax error — line 1: expected from, found 'doing'
$ swift/.build/release/planes-swift shapes manifest.planes
shapes: line 1: expected from, found 'doing'
That is what a foreign line with no from "…" target gets.
The same language, written three times. They are not ports of convenience — they are the check.
| written in | what it is | |
|---|---|---|
interp.py + parser.py + lexer.py |
Python | the reference |
js/interp.mjs + js/parser.mjs + js/lexer.mjs |
JavaScript | a second host, independently written |
grammar/interp.planes + parser.planes + lexer.planes + json.planes |
Planes | the language, in itself |
The third one is the interesting one. grammar/interp.planes is a Planes
interpreter written in Planes; it runs on either of the other two.
A fourth is under way. swift/ is Planes in Swift, a port of the reference held
to the same agreement suites (test_swift_*.py). It lexes, parses, computes
effect surfaces and checks rules; it does not yet run programs. See
swift/README.md.
$ node js/cli.mjs meta run ordinary.planesThat is Planes-in-Planes, running on JavaScript. No Python involved.
What "they agree" means is measured, not asserted. A sweep runs 349 value shapes — every builtin and operator against every kind of value — through all three and compares the tag, the detail text, and the fix clause of whatever each refuses:
$ python3 planes.py -e 'x = "5" + 1'
error — cannot-combine: cannot combine "5" with 1 using +
try: convert first — text of n to build text, or number of t to do arithmetic
All three produce that message, byte for byte. 0 divergences across 349 shapes. Both the effect surface and the derivation graph are computed by the JavaScript stack too, so neither guarantee depends on Python.
js/embed.mjs is the entry point for a host embedding the JavaScript
implementation as a library rather than running js/cli.mjs. Importing it
loads the grammar synchronously, as the import's own side effect — no fs
read, no fetch, no top-level await — so it works unchanged under Node, in
a browser, and in a Cloudflare Worker, and a caller can never hit
GrammarDataError: vocabulary not loaded by forgetting a loading step there
isn't one of:
import { parse, analyse, check, Interpreter, TestHost } from "./js/embed.mjs";
const src = 'show "hi"\n';
const itp = new Interpreter({ host: new TestHost() });
itp.run(src); // ["hi"]
analyse(src); // the effect surface, without running it
check(parse(src).filter((s) => s.__node === "Rule"), analyse(src));It re-exports parse, analyse (the effect surface) and check (rule
checking) under their real names, plus Interpreter and the rest of what
js/cli.mjs itself uses: surface-JSON rendering and diff (asJson,
functionsBreakdown, derivationForm, diff), the in-memory Host/
MemoryHost/TestHost stand-ins, and the value-crossing helpers
(fmt/toJson/toHost/fromForeign) between a Planes value and a plain
host one. Hand-written types ship alongside it in js/embed.d.mts, documenting
the exact-rational number representation among the rest.
Writing your own wording (B4). check() returns Violations whose
asJson() is typed precisely as ViolationJson in js/embed.d.mts — every
field render()'s text is built from, so a host writes its own sentence
straight from the fields instead of parsing render()'s prose:
for (const v of check(rules, surface)) {
if (v.cleared_by) continue; // excepted -- not a refusal
const doc = v.asJson();
const reason = doc.because ?? "this destination is not allowed here";
console.log(`refused: ${reason} (${doc.effect?.target ?? doc.subject})`);
}render() itself is defined this same way — renderViolation(this.asJson())
— a pure function of the ViolationJson document, exported alongside check
for a host that wants Planes' own wording without re-deriving it, or wants to
prove its own wording covers the same fields.
The grammar data itself is generated, the way Swift embeds it in
GrammarData.swift: python3 scripts/js_grammar_gen.py projects
grammar/vocabulary.json, grammar/messages/amber.json and
grammar/core.json into js/embedded_grammar_data.mjs, and --check (run by
test_js_grammar_data.py) fails the suites on any drift.
Measured once on the architect's Mac (Apple M1 Pro, 10 cores, 16 GB, macOS
26.5.2, Python 3.14.7, Node v22.23.1), commit 6b0499e, 2026-09-13. Full
method, per-file numbers, and how each job is shaped by the language (no
range, no dict) are in
benchmarks/published/results.md; rerun
with python3 benchmarks/published/set_a_bench.py and python3 benchmarks/published/set_b_bench.py. Set A's job programs are generated,
not committed (the metacircular suites glob every .planes file in the
repo, and these are sized for a benchmark, not for that) — read them with
python3 benchmarks/published/gen_jobs.py --out DIR.
Set A — four ordinary jobs, each written in Planes, plain Python and plain Node, wall clock including process startup, median of 5 runs:
| job | size | planes.py |
node js/cli.mjs run |
plain Python | plain Node | Planes/Python | Planes/Node |
|---|---|---|---|---|---|---|---|
| hello (baseline) | — | 78.6 ms | 80.1 ms | 27.2 ms | 66.8 ms | — | — |
| word count | 50,000 words | 5624.9 ms | 391.1 ms | 33.7 ms | 82.1 ms | 167.1x | 4.8x |
| record updates | 2,000 records × 50 with updates |
1381.8 ms | 263.2 ms | 32.7 ms | 68.6 ms | 42.2x | 3.8x |
| invoice arithmetic | 5,000 lines | 568.4 ms | 162.4 ms | 58.7 ms | 69.7 ms | 9.7x | 2.3x |
| file transform | 10,000 rows | 5361.8 ms | 951.9 ms | 43.2 ms | 80.1 ms | 124.2x | 11.9x |
Set B — effect-surface time, all 51 corpus/*.planes programs, median
per file and the total of those medians:
shapes_cli.py --json |
node js/cli.mjs shapes |
|
|---|---|---|
| median per file | 56.75 ms | 87.25 ms |
| total | 2894.2 ms | 4450.0 ms |
| process startup (separate) | 27.8 ms | 58.4 ms |
Once startup is set aside, Python and Node spend about the same ~29 ms per
file analysing a program. 3 of 51 corpus programs (5.88%) cross a foreign
boundary — a program whose surface has at least one effect with
"declared": true, i.e. one that came from a foreign … doing … claim
rather than a builtin the analyser understands.
Planes is a tree-walking interpreter that tracks exact-rational arithmetic and full provenance on every value; plain Python and Node do neither. These numbers include that cost — they don't separate it from the interpreter's own per-node overhead — and show Planes running 2–170x slower than plain Python and 2–12x slower than plain Node on ordinary computation, depending on how much of the job is arithmetic (closer) versus per-element interpretation overhead (farther).
Closed, and asserted. The whole reserved surface is 45 names — 32 keywords plus 13 builtins — and both counts are pinned by the test suite, so adding a word is a visible decision rather than a drift. For scale: Python has 35 keywords. The small number here is not the vocabulary, it is the host — seven methods, which is what makes the effect surface computable at all.
32 keywords — only words the parser must see to know a statement's shape:
and as doing each else fail false first
for from foreign give if in let not
nothing of or places plus round rule show
to true use when where why with write
13 builtins — ordinary functions, not keywords, called as count of xs:
ask count join lower normalize number read rest root sine text upper whole
sine takes degrees and returns an approximate value for every
argument, including sine of 0 — its algorithm has no exact path at any of
them. root is the square root, and is the one operation whose exactness its
ARGUMENT decides: root of 9 is exactly 3, root of 2 is approximate, and
a negative argument is refused rather than given an imaginary answer. The
rules, and why the two differ, are in square-root-spec.md.
number is the other direction of text — number of "12.5" is the exact
number 12.5. It refuses rather than guesses: non-numeric text, the empty
string, exponent notation, and a ~-prefixed approximation (the language's
own marker that a value's exact form could not be printed) all name their own
error rather than returning nothing or 0.
8 effect kinds — the closed vocabulary a host can be asked for:
ask clock env random read send show write
clock, random and env are ambient: they make a result depend on
something outside the program, so a function that reads the clock is not pure.
ask and send share the network boundary but mean different things:
ask is fetch-only, a request expecting a response; send is a request that
carries the program's data out (a message, form, upload or report). Like
clock/random/env, send is a foreign-only kind — no keyword, no
builtin, no host method — and stays usable as an ordinary function name. The
one rule for telling them apart: the author of the foreign declaration
decides, by what the request does, not by its HTTP method — a GET can leak
data through its query string and a POST can be a plain lookup. Planes
doesn't guess from the method, and nothing checks beyond the declared label,
the same trust every doing claim has. Data carried in a URL is still traced
by derivation regardless of which kind claims it.
Everything else is an ordinary name. Builtins are shadowable, and the analyser follows the shadow:
to word count of phrase: # `count` used freely inside a name
give 42
to read of source: # shadows the builtin entirely
give "no file is touched"
A shadowed read performs no file effect, and the surface says so. In a
language whose names read as prose, count and text and read are words
people reach for.
Two call forms, deliberately different:
f of xbinds tightly —double of 5 + 1is(double of 5) + 1f xtakes the whole expression —ask "https://" + text of nis one call
Read them as "apply this to that one thing" versus "apply this to what follows".
use http # declare a module before its effects work
use file
x = 5 # binding; `let` optional
xs = [1, 2, 3] # lists
r = { name: "Ada", score: 450 } # records
name = r.name # dot access
r2 = r with score: 500 # record update — a new record
ys = xs plus 4 # list append — a new list
to add of a, b: # function definition
give a + b # return
to fetch stories: # multi-word name, no arguments
give first 30 of everything
r = add of 2, 3 # call; `of` binds tightly
r = add(2, 3) # equivalent
ys = for each x in xs: x * 2 # comprehension
zs = for each x in xs where x > 2: x # with filter
if n > 50: # conditional
show "big"
else:
show "small"
when user is { role: "admin" }: # match on a record's SHAPE
give true
else:
give false
when e is { path }: # bind a field by name
show text of (count of path)
count of xs # builtins
text of n
join of ["a", "b"]
body = ask "https://..." # network round-trip, needs `use http`
or fail as api-down # rename any failure to a domain error
x = risky() or fail as e: # ...or catch it, as an ordinary record
show e.tag
show e.detail
show e.fix
fail "the order is empty" as empty-order # raise one yourself
fail { message: "...", fix: "add a line" } as t # ...naming the fix
write xs to "out.json" # file write, needs `use file`
why total # derivation query
A caught error is an ordinary record, discriminated by shape rather than by
type: { tag, detail, fix, path }. All four fields are always present, and
nothing when they do not apply — because a missing field is no match under
when, so an absent field would make when e is { fix }: silently skip every
error that names none.
0.1 + 0.2 is 0.3. 1 / 3 is one third. 9007199254740993 * 2 is exact.
$ python3 planes.py money.planes
subtotal 59.97
tax 4.947525
total 64.917525
due 64.92
Numbers are exact rationals — not floats, not Decimal. The reason is why: a
derivation containing a silent rounding step answers a question about the
answer, not about the program. Floats round on almost every operation; Decimal
rounds on division. Exact rationals never round, so why reports arithmetic
that actually happened.
| float | Planes | |
|---|---|---|
0.1 + 0.2 == 0.3 |
false | true |
round 2.675 to 2 places |
2.67 | 2.68 |
1.1 * 3 |
3.3000000000000003 | 3.3 |
0.1 * 3 |
0.30000000000000004 | 0.3 |
Approximation is visible. A value with no finite decimal form prints with a
leading ~: 1 / 3 shows as ~0.333333333333. It is still exact internally —
(1 / 3) * 3 is exactly 1 — the marker only says the text is an
approximation.
Rounding is a named operation, so it appears in the derivation:
due = 64.92
round to 2 places = 64.92
total = 64.917525
+ = 64.917525
subtotal = 59.97
tax = 4.947525
Foreign numbers become exact at the boundary. A JSON 0.1 is one tenth from
then on, so arithmetic on fetched data is as exact as on literals.
The cost is bounded, not hidden. Adding many fractions with unrelated denominators grows the denominator. Summing 2000 distinct fractions takes about 10 ms; past the bound an operation is refused rather than silently rounded, because a refusal is visible and a rounding is not.
The same numbers, in every implementation. Arithmetic is exact by
construction, and sine and root — the only two operations that can return
an approximate value — compute it by the identical deterministic algorithm in
Python, JavaScript, self-hosted Planes, and Swift: 1724 angles and 237
radicands, checked byte for byte, agree across all four
(test_sine_agreement.py).
shapes.py computes what a program can do, without running it. The runtime
effect log records what one run did. The two must agree, and that agreement
is enforced as an oracle over every example program in the repo — which is the
one check that can catch an unsound analyser.
The upgrade diff is the case it exists for. It exits 1, so it works as a CI gate:
$ python3 shapes_cli.py --diff demo/v1.planes demo/v2.planes
demo/v1.planes -> demo/v2.planes
NEW BOUNDARIES CROSSED: network
+ network: ask https://telemetry.example.com/collect?data=['debug', 'verbose']
+ network: ask https://telemetry.example.com/collect?data={...} (computed)
Two things worth knowing about how it works.
Effects propagate through the call graph by fixed point, not by a tree walk, so a caller inherits everything its callees do, transitively, and mutual recursion terminates.
A library is not pure just because nothing runs at load time. effects is
what running the file performs; declared is what any function in it can do if
called. Package queries read declared — otherwise a library whose only network
call sits one function deep would be indexed as harmless.
Effect targets resolve through variables, concatenation, and call arguments, so
the host stays visible instead of collapsing to {...}:
let base = "https://api.example.com"
let endpoint = base + "/users"
x = ask endpoint → ask https://api.example.com/users
Widening to unknown is always sound — it costs precision, never correctness.
Names assigned inside a branch or loop widen at the join, and recursive
functions are never specialised, because binding n = 3 at a call to
countdown of 3 would report show 3 and miss 2, 1, 0.
Provenance is carried on values via a Deriv node, never in a type or a
signature. apply_op has no idea derivation exists — results are wrapped after
the fact. That is the architectural difference from label-propagation schemes:
nothing spreads into the signature of +.
why z → 8 from x (5) + y (3)
why result → 5 from add(2, 3) = a (2) + b (3)
why bumped → 500 from s ({record}).score + 50
Full transitive tree, back to where each value entered the program:
label = REQUESTS
upper of = REQUESTS
name = requests
.name = requests
info = {record}
.info = {record}
pkg = {record}
ask https://pypi.org/pypi/requests/json = {record}
<- entered at network:https://pypi.org/pypi/requests/json
origins(value) returns just the boundary crossings a value depends on. Both
why and origins are implemented in all three stacks, including the
self-hosted one.
Governance is a plane over the effect surface, not a feature bolted into the runtime. A rule names itself, names a subject, and forbids or permits an effect:
rule [readings-stay-local] anything may not ask to "https://metrics.internal/ingest"
Checked statically, against the computed surface, exiting 1 on a violation:
$ python3 shapes_cli.py demo/rules/violation.planes --rules
[readings-stay-local] violated at line 9.
ask https://metrics.internal/ingest
rule declared at line 1: anything may not ask to "https://metrics.internal/ingest"
Rules can permit as well as forbid, can name a narrower subject than
anything, and can supersede one another by name and fingerprint — so a
later rule that loosens an earlier one has to say which one, and the pair is
reported rather than resolved by declaration order. A rule that could never
fire is reported as vacuous rather than passing silently.
A rule's target covers that address and everything under it (B2). For a
target that parses as scheme://host[:port][/path], rule [x] anything may not ask to "https://api.example.com/ingest" also forbids
https://api.example.com/ingest/v2, but not
https://api.example.com/ingestion (the path must match at a / boundary)
and not https://tracker.example.com/ingest or
https://api.example.com.evil.com/ingest (scheme and host must match,
case-insensitively; a different host, or a subdomain, is never covered). A
bare host (no path, or /) covers every path on it; a path ending in /
covers everything under it but not the bare path itself.
A host written with a leading dot is a tree: it covers that host and every host under it.
rule [no-doubleclick] anything may not ask to "https://.doubleclick.net"
because "it is only here to follow the reader"
forbids https://doubleclick.net/, https://ad.doubleclick.net/pixel.gif
and https://a.b.doubleclick.net/. A tree is matched at the host's end, and
the dot before the name is required, so it never covers
https://evildoubleclick.net/ or https://doubleclick.net.evil.com/. It is
the cookie and Public Suffix List reading of a leading dot, and it is opt-in
by spelling: a host without the dot means exactly one host, as above. A
permit can still carve one address out of a tree — forbid .x.com, permit
https://login.x.com with supersedes — and a dot with no host after it
(https://.) is refused, naming the fix. The tree is why the surface format
is version 3. Ports are part of
the host and compared exactly as written — https://x and https://x:443
are different targets, with no default-port folding. The effect's own query
string and fragment are ignored, but a rule's own target may not carry one —
rule [x] ... to "https://x?y=1" is refused at check time. A target that
isn't URL-shaped (a file path, a queue:send-style name, console text)
still matches exactly, as every rule target did before B2.
Forbidding ask also forbids send to the same address (B1) — a rule
written before send existed never weakens under the new kind. Forbidding
send forbids only send; permitting ask permits only ask. A permit's
kind must match an effect's actual kind exactly to clear it, never
widened: rule [deny] anything may not ask plus
rule [ok] anything may ask to "https://a" supersedes [deny] @fp still
forbids a send to https://a — only a permit written against send
itself excepts one.
A rule can also declare that it and another must never both apply —
rule [b] anything may not write contradicts [a] — an authored
incompatibility, distinct from the structural conflict the checker detects
on its own. When both rules of a declared pair apply to the same program,
the checker reports it as a contradiction, counted the same as a real
violation.
--json --rules reports every fact above as a structured field, not only
as render()'s prose (B4) — a rule's own subject, an effect's computed/
declared flags, a vacuous rule's situation, a contradiction's two sides —
so a host writes its own wording without parsing rendered text. See
docs/surface-format-v3.md's "Writing your own
wording".
Two forms carry human reasons into the program without changing what it does:
cap = 200 because "board policy, ratified March"
note:
from "GDPR Article 17"
derives-from [refund-cap]
because attaches to a binding and surfaces in why:
$ python3 planes.py annotated.planes
200 from 200
because "board policy, ratified March"
refund 150 approved: true
Inertness is a guarantee, not a convention. Stripping every annotation from
a program must not change its output, its effect log, or its computed surface —
and that is asserted over every .planes file in the repo, not over a sample.
why output is allowed to differ, because that is the point of writing one.
A message that reports a mismatch without saying what to write instead leaves its author — human or machine — with a true statement and no next move. So every error names its fix, and that is counted rather than asserted:
$ python3 errors_coverage.py
names a fix 120 of 125 (96%)
deliberately names none 5 of 125 (4%)
should name one and does not 0 of 125 (0%)
120 raise sites across interp.planes, parser.planes, lexer.planes, json.planes:
names a fix 80 of 120 (67%)
deliberately names none 40 of 120 (33%)
should name one and does not 0 of 120 (0%)
Both work lists are zero — the commitment is kept in the reference implementation and in the self-hosted one.
The middle state is the load-bearing one. A site that names no fix must say
why at the raise site, in words: the parser's generic token gate knows which
token was due and not what the author meant, and fail's own message belongs to
whoever wrote it, so the language must not attach its advice to a sentence it
did not write. A silence without a stated reason counts as a gap.
errors_coverage.py reports and never fails the build — an honest one-line
error should not be un-committable. The design record is
docs/error-messages.md.
use http and use file name builtin capability modules. use config names a
file, config.planes, resolved relative to the importer.
A package's effect surface includes the surface of everything it imports:
$ python3 shapes_cli.py demo/app/main.planes --no-follow
file:
write out.json
unresolved calls: package
main.planes contains no network code — the ask is in net.planes and the
base URL in config.planes. Following imports finds it and resolves the exact
URL across all three files. Not following says so, rather than reporting a clean
surface it cannot vouch for.
Names are flat across a module graph, and collisions are errors. api base
is called as api base, not config.api base, because multi-word names already
read as prose. Flat names make a collision genuinely ambiguous, so it is
reported:
module error — two modules define the same name:
'load record' is defined in cache.planes, loader.planes
try: rename one of them — names are flat across modules,
so 'load record' has to mean one thing
One of those reads a file and the other hits the network. Letting load order
decide would mean the same program behaved differently depending on the order of
its use lines, with nothing to read that explained why.
A collision is fixed at the point of use, because a consumer of two colliding modules usually cannot edit either one:
use loader
use cache with load record as load cached
fresh = load record of "requests" # loader's, over the network
old = load cached of "requests" # cache's, from a file
The rename replaces the exported name rather than adding an alias — registering both would put the collision straight back.
A function implemented in the host declares what it does. Planes cannot see inside the host, so it never guesses:
foreign sort of xs from "builtins.sorted" doing nothing
foreign now from "time.time" doing clock
foreign grab of u from "x.y" doing ask, clock
A declared effect can name where it goes, either as a fixed destination or as a parameter the caller supplies:
foreign send of x from "m.post" doing send "https://api.example.com"
foreign fetch of url from "u.urlopen" doing ask url
The parameter form is the valuable one: at a call site with a known argument, constant propagation resolves the real destination, so a host name survives a foreign boundary — which is what makes the diff meaningful across FFI.
The doing clause is a claim by whoever wrote the line, not a fact the
analyser derived, and the surface says so:
ambient:
clock time.time (declared, not verified)
Omitting doing does not mean pure. It means unknown, and the surface
reports the hole rather than hiding it:
foreign:
unknown — m.f declares no effects
this surface is incomplete: a foreign function states no effects
That default is the whole safety property. Deriving effects from the host is impossible in general — it would mean analysing CPython, then a C extension — and the failure would be silent: an analyser that cannot see inside would report "pure", publishing a guess as a fact.
Planes runs on a host: whatever actually performs an effect. A host is 7 methods, and that is the entire requirement the language places on a machine.
| method | for |
|---|---|
ask |
a network round-trip |
read |
read a file |
write |
write a file |
show |
emit a line |
clock |
the time |
resolve |
find a foreign function |
parse_json |
parse JSON text |
It is small because the effect vocabulary is closed: a host cannot be asked for more than the language can name. Every one of the seven has a live caller — checked mechanically, so a method that stops being used becomes visible instead of becoming dead surface a second host would have to implement for nothing.
from host import TestHost
from interp import Interpreter
host = TestHost(responses={"https://x/y.json": '{"n": 1}'})
i = Interpreter(host=host)
i.run('use http\nr = ask "https://x/y.json"\nshow text of r.n')A foreign target is opaque to the language — the parser stores it as a
string and the analyser never reads it — so node:fs#readFile and
crate::mod::fn parse and analyse today. Changing hosts needs no language
change, which is how the JavaScript implementation exists at all.
The language describes itself as data, generated from the implementation rather than hand-kept, so a tool never has to parse prose to learn the vocabulary.
| file | what it holds |
|---|---|
grammar/vocabulary.json |
keywords, builtins, effect kinds, token classes |
grammar/errors.json |
every error site: tag, class, template, slots, fix |
grammar/rules.json |
every rule form the parser accepts |
grammar/core.json |
the subset a self-hosting implementation may use — interp.planes and every file it reaches through use |
{
"id": "interp.cannot-compare.equal-4",
"kind": "error",
"class": "PlanesError",
"tag": "cannot-compare",
"source": "interp.py:119",
"raised_in": "equal",
"template": "records have different fields: {sorted(set(a) ^ set(b))}",
"slots": ["sorted(set(a) ^ set(b))"],
"fix": "compare records with the same fields"
}grammar_gen.py --check fails the build if any of these drifts from the code it
describes. shapes_cli.py --json emits an effect surface in the same spirit —
the exact format, field by field, is docs/surface-format-v3.md and its JSON
Schema, grammar/protocols/surface-v3.json.
An orientation, not an inventory. For the complete list, ask the repo:
git ls-files.
lexer.py parser.py interp.py |
the reference implementation |
planes_num.py planes_text.py |
exact rationals; text as code points |
shapes.py |
the static effect analyser |
rules.py |
the rule plane, checked against a surface |
render.py |
the canonical printer — parse, render, reparse, agree |
modules.py host.py |
module graph; the host seam |
planes.py shapes_cli.py |
the two CLIs |
js/ |
the JavaScript implementation, plus Node and browser hosts |
index.html |
run and analyse Planes in a browser, no build step |
paint.html |
run a Planes program and paint its show output onto a canvas |
grammar/*.planes |
Planes, written in Planes — lexer, parser, interpreter, JSON |
grammar/*.json |
the machine-readable surfaces above |
corpus/ |
the canonical corpus — every construct, as programs |
demo/ |
small programs for the diff, index, rules and module demos |
identity/ |
the visual identity — marks, lockups, social card, all generated by render_logo.py |
scripts/ci.sh |
the gate |
docs/ |
design records |
reports/ |
one REPORT_*.md per build, including what each disproved |
$ bash scripts/ci.sh
...
all checks passedIt runs the suites, the JavaScript tests, the locked-construct audit, the
grammar-data check, the core-subset check for every self-hosted file, the
coverage reports, ruff and mypy. scripts/ci.sh --fast skips the twelve slowest
suites for iteration and is not the gate — it skips the
cross-implementation agreement, which is the thing worth checking.
The suite and ok counts print on the run's own == suites: … == line. They are
not reproduced here: this README carried 56 files, 1124 oks until it was six
suites and a hundred and fifty-three oks out of date, which is the same defect
class as any other number nobody re-derives.
"The core-subset check for every self-hosted file" is worth reading twice,
because it was not true when it was written. core_check.py checked
interp.planes alone, and a second host runs interp.planes plus everything it
reaches through use. It follows the graph now, so one run covers all five
self-hosted files — and the sentence and the checker finally agree.
Two habits it enforces, both learned the hard way:
A test file that reports no result fails the build. Five times in this repo's history something existed, passed, and was never executed — two suites with no runner, 47 JavaScript tests nobody ran, and seven verification scripts of which two had gone quietly wrong. A warning depends on somebody reading it.
Every test-shaped file is counted against what the gate runs. The check
reads the glob out of ci.sh rather than restating it, so the two cannot drift
apart silently.
Working, and checked. Nothing here is a specification — the implementation is
the specification, and grammar/*.json is its machine-readable projection. The
reports/REPORT_*.md files are the build record; each one ends with what that
build disproved about its own plan, which is usually the most useful part.
Apache License 2.0 — Copyright 2026 R. Michael Thomas.
identity/render_logo.py embeds the outlines of the word "Planes" set in
Red Hat Display,
© 2019 Red Hat, Inc., under the SIL Open Font License 1.1. No font file is
redistributed here. See NOTICE.