Skip to content

Developer guide

Working on astero itself.

The one idea

Nothing derived is ever authored. Before adding a table, ask whether a declaration derives it. Every defect this design came out of had that one shape.

Every derivation needs an oracle

A derivation without one is a claim. The corpus is the standard library, and the oracles are already there:

derivation oracle
ctx CPython's parser
scopes and bound names symtable
emission emit, then reparse
a consumer's IR grammar its own hand-written pass, frozen

Thresholds in the integration tests hold a measured line, so a regression fails and an improvement does not have to be chased.

A switch-over destroys the oracle it was checked with, because afterwards both sides are the same code. When a consumer adopts a derivation, freeze the replaced pass in the test and hold the derivation to that, plus a test asserting the original really is gone. Otherwise the test goes on passing while asserting nothing.

Two failure modes

Drift: two enumerations, one stale. Loud, and what the design is for.

Answering the wrong question: quieter, because nothing disagrees with anything. SCOPES versus BINDING_SCOPES, ident_slots() versus ident_slots(ns). Both were correct derivations of the wrong thing. A consumer test that stated what the consumer meant found them.

Measure before building

Four planned steps were cancelled by measuring first: a bracketing rewrite, an attribution layer, a document-algebra port, and adopting tables in the compiler it was written for. Each time the argument was arithmetic nobody had checked, and each measurement cost minutes.

A correct derivation and an undrifted duplication are different claims, and only the second justifies the change. tables reproduced twelve hand-written dictionaries cell for cell and was still not adopted: the rule it would state once was written six times and all six agreed.

Build what a consumer asks for

tables was built because a shape looked convertible, and sat unused for the project's life. rewriting grew statement splicing and stop_at because a real pass could not be expressed without them, and both landed in an afternoon.

The corollary: a second consumer is worth more than a feature. Three defects and three interface constraints in astero were found by adopting it somewhere new, and none by reading it, including guards that silently failed on every type system whose type_of returns instances.

The condition language stays two words

Present(field) and Absent(field). Not a callable. Admitting a predicate readmits host code into the declaration, which the design removes. The limit has bitten: PEP 649 annotation scopes depend on a block's contents, which this cannot say, so they are an accepted residual rather than a reason to widen.

Authored data must be gated

Where hand-written data is unavoidable (a table a type checker needs to narrow, a frozen oracle, a code snippet in the docs), write a test asserting it still equals what it mirrors. docs/tutorial.md is checked against examples/tinypy/ for exactly this reason.

No inline suppressions

No # noqa, no # type: ignore. Fix the cause, or make it a stated decision in ruff.toml with a comment saying why. The pre-commit hook runs a newer ruff than the project pins, so it catches things make lint does not.

Run it on more than one interpreter

Much of this is version-sensitive and a green run on one Python says nothing about the others:

for v in 3.11 3.12 3.13 3.14 3.15; do
  PYTHONPATH=src uv run --python "$v" --with pytest --no-project \
    python -m pytest tests/a_unit -q -p no:cacheprovider
done

_field_types is 3.13+. Comprehensions stopped opening scopes in 3.12. PEP 695 blocks are spelled differently on 3.12 and 3.13. PEP 649 adds annotation blocks from 3.14. symtable.get_type() returns a string before 3.13 and an enum after. CPython injects synthetic names (.0, .defaults, .type_params) that oracles must filter.