In-language test framework: quilon test over describe / it - #233
Merged
Conversation
…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
force-pushed
the
feat/test-harness
branch
from
August 27, 2026 07:58
346e5f1 to
4bee68d
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PARKED — do not merge. Opened for review only.
quilon testruns a file's top-leveldescribeblocks. Everything else strips them, so a suite can sit beside the code it tests and cost a release build nothing.Execution model
A suite is a
.qnfile with top-leveldescribe(…)calls and no^. The parser routes such a call into a newProgram.test_blocksfield rather thanitems, and the front end takes one of two paths (driver::TestBlocks):Strip—check,compile,build,run. The field is simply never read, so the blocks reach neither the type checker nor codegen.Run—quilon test.driver::synthesize_test_entryappends a^ = () -> Numwhose body is each block in source order followed byreportSummary(), whoseNumresult is the process status. Synthesized nodes get spans in aSYNTHESIZED_FILEid 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 inquilon-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 functionscore.testexports —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
describeis the marker — nocfg, no attribute. A file that is only test blocks (plus fixtures: helpers and types are fine) is not a compilation unit, sorun/compile/buildexit 0 in silence rather than reporting a missing^. Verified: acompileof a file carrying both code and tests emits nothing of the harness — the test asserts that over the wholedefinelist.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:columnwhere it stopped. There is no "N passed, M failed" tally across cases yet; that needs a matcher API that renders and continues (expectwas cut from this PR for redesign).Also in here
--debugpanic: an opaque DWARF pointee had an empty name, which LLVM rejects, so any-gbuild of a program holding a function value crashed. Gated byexamples/higher_order.qnunder debug codegen.docs/corelib/test.mdgains the harness, the seam, and the stripping rule;docs/LANGUAGE.md,CLAUDE.md,docs/ROADMAP.mdgainquilon test.examples/test_suite.qnis 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 -- --checkclean,cargo clippy --workspace --all-targets --all-features -- -D warningsclean,cargo test --workspace778 passed / 0 failed.🤖 Generated with Claude Code