Skip to content

In-language test framework: quilon test over describe / it - #233

Merged
assapir merged 24 commits into
mainfrom
feat/test-harness
Aug 27, 2026
Merged

In-language test framework: quilon test over describe / it#233
assapir merged 24 commits into
mainfrom
feat/test-harness

Conversation

@assapir

@assapir assapir commented Aug 26, 2026

Copy link
Copy Markdown
Owner

PARKED — do not merge. Opened for review only.

quilon test runs a file's top-level describe blocks. Everything else strips them, so a suite can sit beside the code it tests and cost a release build nothing.

quilon test                          # every suite under the current directory
quilon test examples/test_suite.qn   # one file
quilon test tests/                   # one directory
examples/test_suite.qn
Text
  ✓ trims both ends
  ✓ finds a part
  splitting
    ✓ splits on a separator

9 cases passed

Execution model

A suite is a .qn file with top-level describe(…) calls and no ^. The parser routes such a call into a new Program.test_blocks field rather than items, and the front end takes one of two paths (driver::TestBlocks):

  • Stripcheck, compile, build, run. The field is simply never read, so the blocks reach neither the type checker nor codegen.
  • Runquilon test. driver::synthesize_test_entry appends a ^ = () -> Num whose body is each block in source order followed by reportSummary(), whose Num result is the process status. Synthesized nodes get spans in a SYNTHESIZED_FILE id of their own, since spans key the type oracle.

One suite per process. Cases assert with core.test's existing assertions, which are fail-fast (__exit(101)), so a run of several suites spawns this binary once per suite with stdio inherited — a failure ends its own suite, not the whole run. Always the JIT; never a native build.

State the harness needs and the language cannot hold (a top-level := does not persist across calls) lives in four thread-local counters in quilon-rt::test_registry__test_suite_enter/_leave, __test_case_passed, __test_passed. All are () -> Num, so codegen lowers the family through one path.

Reporter seam

The registry counts and nests; it renders nothing. All rendering is .qn, in three functions core.test exports — reportSuite, reportCase, reportSummary — and the synthesized entry calls the last one by a name the driver owns (driver::REPORTER_SUMMARY_FUNCTION). A reporter of its own defines the same three.

Stripping

describe is the marker — no cfg, no attribute. A file that is only test blocks (plus fixtures: helpers and types are fine) is not a compilation unit, so run/compile/build exit 0 in silence rather than reporting a missing ^. Verified: a compile of a file carrying both code and tests emits nothing of the harness — the test asserts that over the whole define list.

Known limitation

Cases assert with the fail-fast assertions, so the first failure reports its frame and exits 101: the failing case and everything after it go unreported, and no summary prints. The reporter claims only what it can know — "N cases passed" on a clean run, or the frame naming file:line:column where it stopped. There is no "N passed, M failed" tally across cases yet; that needs a matcher API that renders and continues (expect was cut from this PR for redesign).

Also in here

  • Fixes a pre-existing --debug panic: an opaque DWARF pointee had an empty name, which LLVM rejects, so any -g build of a program holding a function value crashed. Gated by examples/higher_order.qn under debug codegen.
  • docs/corelib/test.md gains the harness, the seam, and the stripping rule; docs/LANGUAGE.md, CLAUDE.md, docs/ROADMAP.md gain quilon test.
  • examples/test_suite.qn is the wired-in demonstration, gated by the suite.

Review findings applied

An unparseable suite is now run (and reported) instead of silently vanishing and passing, and a path that does not exist fails rather than reporting an empty run — the worst CI failure modes. Discovery no longer follows symlinks, so a link back up the tree cannot recurse forever. A missing reporter is reported at the describe, not at a synthesized span. The tests-only decision reuses the front end's parse instead of doing its own. The argument passthrough is gone: the synthesized ^ takes no parameters and could never have read it.

Gate: cargo fmt --all -- --check clean, cargo clippy --workspace --all-targets --all-features -- -D warnings clean, cargo test --workspace 778 passed / 0 failed.

🤖 Generated with Claude Code

assapir and others added 24 commits August 27, 2026 10:54
…ed entry

A top-level `describe(...)` call parses into `Program.test_blocks` rather than
`items`, so every command but `quilon test` compiles the file without its tests.
`quilon test` synthesizes the entry point that runs each block in order and ends
with the reporter's summary.

The registry (`quilon-rt::test_registry`) holds what a run needs and Quilon has
no storage for: nesting depth, the case in progress, and the pass/fail totals. It
renders nothing — that is the reporter seam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`quilon test [path]` finds every suite under a path — a `.qn` file with top-level
`describe` blocks — and JITs each on its own thread, exiting non-zero if any case
failed. `build`/`compile`/`run` skip a file that is nothing but tests.

`core.test` gains the harness: `describe`/`it` over `() -> $` closures, and an
`expect` overload per scalar type returning a matcher that remembers where the
`expect` was written, so a failure blames your call. Matchers RENDER AND CONTINUE
— the run reports every failing case, not just the first — reusing the diagnostic
frame that `failAt` already drew, now shared as `renderFrame`.

Also fixes a `-g` panic: an opaque DWARF pointee had an empty name, which LLVM
rejects, so any program holding a function value crashed under `--debug`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rust gates for both halves: the parse (a top-level `describe` CALL is a test
block, a `describe =` definition is not), the strip (a release build of a mixed
file emits neither `describe` nor an `expect` overload nor the reporter; a
tests-only file is passed over in silence), and the run (a passing suite exits 0,
a failing case exits non-zero without stopping the run and reports at its own
`expect`, a directory runs each suite with its totals kept separate).

Docs: `docs/corelib/test.md` gains the harness, the matcher table, the reporter
seam, and the stripping rule; LANGUAGE.md and CLAUDE.md gain `quilon test`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The maintainer is redesigning `expect`, so the matcher records, their
trailing-`Site` plumbing, `reportFailure`/`renderFrame`, and the three intrinsics
that only served them are gone. A case now asserts with `assert`/`assertEq`/…,
which are fail-fast: the first failure reports and exits 101, so the reporter
says what it can know — every case up to that point, and a total only on a clean
run. No "N passed, M failed" tally is claimed.

The registry is down to four counters (`__test_suite_enter`/`_leave`,
`__test_case_passed`, `__test_passed`), and the reporter seam to three functions.

Suites now run one process each, so a fail-fast exit ends its own suite rather
than the whole run.

Review findings applied: an unparseable suite is run (and reported) instead of
silently vanishing and passing; discovery no longer follows symlinks, so a link
back up the tree cannot recurse forever; a suite keeps its fixtures without
losing the silent skip; a missing reporter is reported at the `describe` rather
than at a synthesized span; the tests-only check reuses the front end's parse
instead of doing its own; `quilon test` no longer offers an argument passthrough
the synthesized parameterless `^` could never read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With the matchers gone, a release build of a mixed file emits NOTHING of the
harness — the stripping test now asserts that over the whole define list rather
than a handful of names.

And a `-g` gate for the opaque DWARF pointee: `examples/higher_order.qn` under
debug info is a program holding a function value, which is what used to panic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A mistyped path in a CI invocation used to discover no suites and exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`examples/tests_alongside_code.qn` keeps its `describe` blocks next to the `>>`
exports they check, and one case prints a line nothing else in the repository
prints — so a build that shows it would be a build that compiled test code.
`examples/uses_tested_module.qn` imports those exports and prints a marker of its
own, which is what tells "the blocks were erased" apart from "nothing ran".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The examples gate only checks exit codes, so the stripping was asserted from the
inside (no `describe` in the emitted IR) and never observed. Three tests now watch
what a run prints:

- a `describe` block beside an `^`, inline: `run` and a native build print the
  program's marker and never the block's line.
- the shipped module: `run` of it says nothing, and `run`/`build` of the program
  that imports its exports print the marker without the block's line.
- the same module under `quilon test`: that line IS on stdout and its case is
  reported — one file, two commands, opposite outcomes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ld's

The cost side was documented; the consequence was not. `check`/`compile`/`build`
strip the block before the checker sees it, so a broken test compiles clean and
reports success — only `quilon test` catches it. Says so, says to run `quilon test`
in CI, and points "tests beside the code" at the module form that `quilon test`
will actually run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review findings. An imported module's test blocks are dropped at link time
whatever the mode, so the pair test was never evidence of stripping: it is
evidence about the import boundary, and now says so in its name. The same-file
proof is the sibling test, which is where the claim belongs.

Also: reuse `common::tool_available` instead of a fourth private copy, collapse
the thrice-repeated marker assertions into one helper, name the constant for what
it marks, pin the suite's case count so a dropped case fails, and follow the
`mathlib`/`use_module` convention — `use_tested_module.qn`.

The docs sentence gains `run` (it strips too) and drops the overstatement that
`quilon test` refuses any file with an `^`: it refuses one that also has blocks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A file with top-level `describe` blocks and its own `^` was rejected by
`quilon test`, which left tests written next to an executable's entry point
dead: erased from every build and unrunnable. They now work like Rust's
`#[cfg(test)]` neighbours — every command but `quilon test` erases the
blocks and the file's `^` is its entry point as usual, while `quilon test`
compiles the blocks and runs them under the entry point it synthesizes,
dropping the file's own so the two never collide on one symbol.

The shipped example keeps its exports, its `^`, and its tests in one file
and prints a distinct line from each half, so which line comes out says
which halves were compiled.
Only `quilon test` compiles a `describe` block, so a type error inside one
passed every gate the workflow had. Each platform job now runs the built
binary over `examples/` after its `cargo test`, where a failing case or a
suite that does not compile fails the run.
From the correctness and simplification reviews of the change:

- The synthesized entry now displaces anything holding the `^` name, a
  top-level binding included. A `^ = 5` beside test blocks used to survive
  the drop and leave the module with no callable entry point at all.
- `entry_point` returned a declaration nobody read once its diagnostic went
  away; `has_entry_point` goes through `defines_function` instead, leaving
  one generic matcher and one that names `^`.
- `TestBlocks::Strip` and two doc comments still said strip where
  everything user-facing now says erase.
- The synthetic and shipped fixtures had the same shape, so four tests
  covered two behaviors. The shipped example is the proof; what is left of
  the synthetic one is the case it does not cover — an `^` in the MIDDLE of
  the items, whose removal must not disturb what follows.
- Directory discovery is gated on a file that is both a program and a
  suite, the shape the CI step relies on finding.
- The beside-the-code rule had grown into five places; `core.test`'s
  reference owns it and the rest point at it.
Every example file is independent — none imports another for convenience, and
duplicating a helper beats a cross-example dependency. `use_tested_module.qn`
existed only to import `tests_alongside_code.qn` and assert the module's test
block never printed, and that assertion could not fail: link-time import
resolution drops an imported module's test blocks unconditionally, in every
mode. The same-file proof in `tests_alongside_code.qn` — `describe` beside `^`,
one printing what the other must not — is the genuine one, and it stays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`>` now closes a block by default, so the suites, the examples, the fixtures
and the reference no longer need the call's `)` on a line of its own. The
subsection explaining that workaround goes with it — the rule lives in the
language reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@assapir
assapir force-pushed the feat/test-harness branch from 346e5f1 to 4bee68d Compare August 27, 2026 07:58
@assapir
assapir merged commit 85e0599 into main Aug 27, 2026
3 checks passed
@assapir
assapir deleted the feat/test-harness branch August 27, 2026 08:09
@assapir
assapir restored the feat/test-harness branch August 27, 2026 08:10
@assapir
assapir deleted the feat/test-harness branch August 27, 2026 08:11
assapir added a commit that referenced this pull request Aug 27, 2026
main's #233 (quilon test over describe/it) and #246 (matcher
assertions) rewrote docs/corelib/test.md and touched ten LANGUAGE.md
regions. test.md takes main's version with this branch's link targets;
each LANGUAGE.md hunk lands in its page — the intro banner, the
isOk/isNotOk matchers (sum-types), core.test.report in the module
lists, the two-row corelib table, failAt on Site, the readStdin sample
over assert/equals, the quilon test subcommand and its paragraph,
assertion diagnostics, and the three matrix rows + limitations note.
The expect-outside-a-case demo fence carries the ignore marker; the
suite/reporter fences pass the gate as-is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019yDekD82SzZf9LH5xdArZW
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant