Skip to content

Commit c5f6646

Browse files
committed
test: add conformance suite, workspace coverage CI, and testing roadmap
Improve test coverage infrastructure and close gaps surfaced along the way. - Add crates/my-lang/tests/conformance.rs: drives every conformance/valid, conformance/invalid, and examples/*.my fixture through the library parser under `cargo test` (the old shell runner used a removed --parse-only flag). Unimplemented-but-intended grammar is tracked in a fail-closed KNOWN_PARSE_GAPS allowlist so closing a gap forces removal of its entry. - Fix parser: ai_model attributes now accept comma/trailing-comma separators, matching struct-field syntax (was previously unparseable); add regression test. - Add .github/workflows/coverage.yml: cargo-llvm-cov across the workspace with LCOV+HTML artifacts and a ratcheting line-coverage floor (baseline ~46.7%). - Fix three crates that did not compile (so the whole workspace builds/tests): my-fmt (borrow-after-move), my-lsp (non-exhaustive CheckError match missing ExpressionTooDeep), my-lint (invalid multi-codepoint char literals + borrow-after-move); correct my-lint's never-run unit test expectation. - Add TESTING.md: how to run tests/coverage, current state, gaps, and a prioritised improvement roadmap. https://claude.ai/code/session_014uKLLhZiAGNayhLjdxGXUx
1 parent 9c741cb commit c5f6646

7 files changed

Lines changed: 438 additions & 10 deletions

File tree

.github/workflows/coverage.yml

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Line/region coverage for the Rust workspace via cargo-llvm-cov.
3+
#
4+
# Why: the workspace had fuzzing and a stack-depth guard but no coverage
5+
# measurement, so coverage regressions went unnoticed (see TESTING.md). This
6+
# workflow measures coverage on every change, publishes an LCOV report + an
7+
# HTML report as artifacts, writes a summary to the job page, and enforces a
8+
# conservative line-coverage FLOOR so coverage can only ratchet upward.
9+
#
10+
# Raising the floor: bump COVERAGE_FLOOR (and the matching --fail-under-lines
11+
# value below) as coverage improves. It is intentionally a floor, not a target.
12+
name: Coverage
13+
14+
on:
15+
push:
16+
paths:
17+
- 'crates/**'
18+
- 'Cargo.toml'
19+
- 'Cargo.lock'
20+
- '.github/workflows/coverage.yml'
21+
pull_request:
22+
paths:
23+
- 'crates/**'
24+
- 'Cargo.toml'
25+
- 'Cargo.lock'
26+
- '.github/workflows/coverage.yml'
27+
28+
permissions: read-all
29+
30+
env:
31+
# Minimum acceptable line coverage (percent). Ratchet upward over time.
32+
# Baseline at introduction was ~46.7% line coverage (workspace, excl. my-llvm);
33+
# the floor sits just below that so it gates regressions without being flaky.
34+
COVERAGE_FLOOR: "40"
35+
36+
jobs:
37+
coverage:
38+
name: llvm-cov
39+
runs-on: ubuntu-latest
40+
permissions:
41+
contents: read
42+
steps:
43+
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
44+
45+
- name: Setup Rust
46+
uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # stable
47+
with:
48+
toolchain: stable
49+
components: llvm-tools-preview
50+
51+
- name: Install cargo-llvm-cov
52+
run: cargo install cargo-llvm-cov --locked
53+
54+
# Coverage gate. --fail-under-lines must mirror COVERAGE_FLOOR. The LLVM
55+
# back end (my-llvm) needs a system LLVM toolchain, so it is excluded here
56+
# to keep the job hermetic; everything else in the workspace is measured.
57+
- name: Measure coverage (enforce floor)
58+
run: |
59+
cargo llvm-cov \
60+
--workspace \
61+
--exclude my-llvm \
62+
--lcov --output-path lcov.info \
63+
--fail-under-lines "${COVERAGE_FLOOR}"
64+
65+
- name: Coverage summary
66+
if: always()
67+
run: |
68+
{
69+
echo '## Coverage summary'
70+
echo '```'
71+
cargo llvm-cov report --workspace --exclude my-llvm --summary-only
72+
echo '```'
73+
} >> "$GITHUB_STEP_SUMMARY"
74+
75+
- name: Generate HTML report
76+
if: always()
77+
run: cargo llvm-cov report --workspace --exclude my-llvm --html --output-dir coverage-html
78+
79+
- name: Upload coverage reports
80+
if: always()
81+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
82+
with:
83+
name: coverage-report
84+
path: |
85+
lcov.info
86+
coverage-html
87+
if-no-files-found: warn

TESTING.md

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
<!-- SPDX-License-Identifier: MPL-2.0 -->
2+
<!-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> -->
3+
4+
# Testing & Coverage Roadmap
5+
6+
This document records the current state of automated testing in the `my-lang`
7+
workspace and the prioritised plan for improving it. It is a living document:
8+
update it as gaps are closed.
9+
10+
## How to run the tests
11+
12+
```sh
13+
# Whole workspace
14+
cargo test --workspace
15+
16+
# Just the compiler crate
17+
cargo test -p my-lang
18+
19+
# Conformance + example fixtures (parse-level)
20+
cargo test -p my-lang --test conformance
21+
22+
# Coverage (requires llvm-tools-preview + cargo-llvm-cov)
23+
rustup component add llvm-tools-preview
24+
cargo install cargo-llvm-cov --locked
25+
cargo llvm-cov --workspace --exclude my-llvm --summary-only
26+
```
27+
28+
CI runs coverage on every push/PR via `.github/workflows/coverage.yml` and
29+
enforces a conservative line-coverage **floor** (`COVERAGE_FLOOR`). The floor is
30+
a ratchet: raise it as coverage improves; never lower it.
31+
32+
### Baseline (at introduction)
33+
34+
Whole-workspace coverage (excluding `my-llvm`, which needs a system LLVM
35+
toolchain) was **~46.7% lines / 45.3% regions / 48.0% functions**. Notable
36+
per-area gaps:
37+
38+
| Area | Line coverage |
39+
|------|--------------:|
40+
| `my-lang/src/lexer.rs` | ~95% |
41+
| `my-lang/src/parser.rs` | ~79% |
42+
| `my-lang/src/checker.rs` | ~61% |
43+
| `my-lang/src/interpreter.rs` | ~58% |
44+
| `my-lang/src/stdlib.rs` | ~42% |
45+
| `my-lang/src/types.rs` | ~31% |
46+
| `my-lang/src/token.rs` | ~21% |
47+
| `my-mir/src/lib.rs` | ~21% |
48+
| `my-pkg/src/lib.rs` | ~10% |
49+
| `my-lsp/src/lib.rs` | ~16% |
50+
| `my-lang/src/main.rs`, `src/visitor.rs`, `my-test`, `my-lsp/main.rs` | 0% |
51+
52+
## Current state (snapshot)
53+
54+
The active build is the `crates/*` Cargo workspace. Test density is very uneven:
55+
the compiler front end (lexer/parser/checker/interpreter) is reasonably covered,
56+
while the middle/back end, the CLI, and most support binaries are barely tested.
57+
58+
| Crate | ~LOC | Inline tests | Integration files | Status |
59+
|-------|-----:|-------------:|------------------:|--------|
60+
| `my-lang` (compiler) | 14,156 | 140+ | 3 | Front end good; `stdlib.rs`, `ast.rs`, `types.rs`, `token.rs`, `visitor.rs` have **no** unit tests |
61+
| `my-cli` | 764 | 0 | 0 | The `my` binary — **untested** |
62+
| `my-mir` | 1,596 | 1 | 0 | MIR lowering nearly untested |
63+
| `my-llvm` | 1,077 | 1 | 0 | Codegen nearly untested |
64+
| `my-lsp` | 648 | 1 | 0 | Language server nearly untested |
65+
| `my-ai` | 778 | 5 | 0 | Light; no async end-to-end tests |
66+
| `my-hir` | 666 | 1 | 0 | Thin |
67+
| `my-fmt` | 529 | 2 | 0 | Thin |
68+
| `my-lint` | 444 | 1 | 0 | Thin |
69+
| `my-pkg` | 377 | 1 | 0 | Thin |
70+
| `my-test` | 344 | 0 | 0 | **Untested** |
71+
| `my-debug` | 266 | 0 | 0 | **Untested** |
72+
| `my-dap` | 173 | 0 | 0 | **Untested** |
73+
| `my-parser` | 41 | 1 | 1 | Stub (all methods `Ok(())` / TODO) |
74+
75+
### Structural issues found
76+
77+
1. **Partly-orphaned duplicate source tree.** The repository root contains a
78+
second `src/`, `lib/`, `tests/`, and `fuzz/` tree alongside the active
79+
`crates/*` workspace. The picture is mixed:
80+
- `tests/integration_test.rs` (20 tests) **is** run — `crates/my-lang/Cargo.toml`
81+
wires it in via `[[test]] path = "../../tests/integration_test.rs"`.
82+
- `tests/property_tests.rs` (34 tests) is **not referenced anywhere** and
83+
never runs.
84+
- The root `src/` and `lib/` are **not referenced** by any crate; the root
85+
`src/lib.rs` also references modules that do not exist there, so it cannot
86+
compile as a package.
87+
**Action:** wire in (or port) `property_tests.rs`, and delete the orphaned
88+
root `src/`/`lib/` duplicates so the layout has a single source of truth.
89+
90+
2. **Conformance suite was shell-only and broken.** `conformance/run_conformance.sh`
91+
drove `my-cli` through a `--parse-only` flag that no longer exists (the CLI
92+
uses subcommands such as `parse`/`check`). It was therefore easy to skip and
93+
silently non-functional. → **Done:** added `crates/my-lang/tests/conformance.rs`,
94+
which exercises every fixture against the library API under `cargo test` (and
95+
thus under coverage). See "Known parse gaps" below.
96+
97+
3. **No coverage measurement.****Done:** added `.github/workflows/coverage.yml`
98+
(`cargo-llvm-cov`) with an LCOV + HTML artifact and a ratcheting floor.
99+
100+
4. **Several crates did not compile.** Enabling a workspace-wide build revealed
101+
that `my-fmt`, `my-lsp`, and `my-lint` did not compile at all (a borrow-after-move,
102+
a non-exhaustive `match` missing the `ExpressionTooDeep` checker variant, and
103+
invalid multi-codepoint `char` literals, respectively). This strongly implies
104+
current CI only builds `my-lang`. → **Done:** all three are fixed in this
105+
change, and the new coverage job now builds and tests the whole workspace, so
106+
such breakage cannot recur silently. `my-lint`'s only unit test had also never
107+
run and asserted the wrong count; it now matches actual behaviour.
108+
109+
5. **Thin negative-path testing.** Outside `checker.rs`, very few tests assert
110+
on *which* error/span is produced. The `conformance/invalid/*.my` fixtures
111+
now assert rejection, but unit tests should also pin specific
112+
`ParseError`/`CheckError` variants.
113+
114+
## Known parse gaps (surfaced by the new conformance test)
115+
116+
Wiring up the conformance fixtures immediately found real defects/feature gaps.
117+
One was a genuine bug and is **fixed**; the rest are tracked as a fail-closed
118+
allowlist in `crates/my-lang/tests/conformance.rs` (`KNOWN_PARSE_GAPS`). When a
119+
gap is closed, the test forces removal of its allowlist entry.
120+
121+
| Fixture | Gap | Status |
122+
|---------|-----|--------|
123+
| `v03_ai_model.my` | `ai_model` blocks rejected comma-separated / trailing-comma attributes (inconsistent with `struct`) | **Fixed** in `parser.rs` (`parse_ai_model_attr`) + regression test |
124+
| `v04_let.my` | assignment statements (`y = y + x;`) not parsed | Tracked (allowlisted) |
125+
| `v06_match.my` | `match` not accepted as a function tail expression | Tracked (allowlisted) |
126+
| `v07_agent.my` | unit type `()` in effect op signatures not parsed | Tracked (allowlisted) |
127+
| `v08_effect.my` | lambda syntax (`\|n\| => expr`) not parsed | Tracked (allowlisted) |
128+
129+
## Prioritised improvement plan
130+
131+
1. **Resolve the duplicate tree** (item 1 above). Highest leverage — until then
132+
coverage numbers are misleading.
133+
2. **Close the known parse gaps**, removing each `KNOWN_PARSE_GAPS` entry as its
134+
feature lands (assignment statements, match tail expressions, unit type in
135+
effect ops, lambdas).
136+
3. **Test the middle/back end:** golden tests for `my-mir` lowering and `my-llvm`
137+
codegen (snapshot IR for small programs).
138+
4. **Test `my-cli`:** drive the built `my` binary (or `assert_cmd`) for
139+
argument parsing, exit codes, and each subcommand (`parse`, `check`, `lex`,
140+
`run`).
141+
5. **Cover `stdlib.rs`** — the largest untested unit (~1,900 LOC) and likely
142+
high-churn (JSON, maps, strings, fs, dates).
143+
6. **Add async tests for the AI paths** (`my-ai`, `library::mylang::*`) using the
144+
existing `MockAiClient` to exercise streaming and tool-call flows end-to-end.
145+
7. **Strengthen negative-path unit tests** — assert specific error variants and
146+
spans, especially for the `conformance/invalid` cases.
147+
8. **Cover the remaining untested binaries:** `my-test`, `my-debug`, `my-dap`.
148+
9. **Ratchet `COVERAGE_FLOOR`** upward as the above lands.

crates/my-fmt/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ fn main() {
3737

3838
// Write the output file
3939
let output_path = args.output.unwrap_or(input_path);
40-
match fs::write(output_path, formatted) {
40+
match fs::write(&output_path, formatted) {
4141
Ok(_) => println!("Formatted {}", output_path.display()),
4242
Err(e) => eprintln!("Error writing file: {}", e),
4343
}

crates/my-lang/src/parser.rs

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -274,25 +274,32 @@ impl Parser {
274274
let attr_name = self.parse_ident()?;
275275
self.expect(TokenKind::Colon)?;
276276

277-
match attr_name.name.as_str() {
277+
let attr = match attr_name.name.as_str() {
278278
"provider" => {
279279
let value = self.parse_string_lit()?;
280-
Ok(AiModelAttr::Provider(value))
280+
AiModelAttr::Provider(value)
281281
}
282282
"model" => {
283283
let value = self.parse_string_lit()?;
284-
Ok(AiModelAttr::Model(value))
284+
AiModelAttr::Model(value)
285285
}
286286
"temperature" => {
287287
let value = self.parse_float_lit()?;
288-
Ok(AiModelAttr::Temperature(value))
288+
AiModelAttr::Temperature(value)
289289
}
290290
"cache" => {
291291
let value = self.parse_bool_lit()?;
292-
Ok(AiModelAttr::Cache(value))
292+
AiModelAttr::Cache(value)
293293
}
294-
_ => Err(self.error("valid ai_model attribute")),
294+
_ => return Err(self.error("valid ai_model attribute")),
295+
};
296+
297+
// Optional separator/trailing comma, matching struct-field syntax.
298+
if self.check(TokenKind::Comma) {
299+
self.advance();
295300
}
301+
302+
Ok(attr)
296303
}
297304

298305
fn parse_prompt_decl(&mut self) -> ParseResult<PromptDecl> {
@@ -2229,6 +2236,27 @@ mod tests {
22292236
}
22302237
}
22312238

2239+
#[test]
2240+
fn test_ai_model_decl_comma_separated() {
2241+
// Comma-separated attributes with a trailing comma, matching struct-field
2242+
// syntax. Regression for the conformance fixture v03_ai_model.my.
2243+
let input = r#"
2244+
ai_model Classifier {
2245+
provider: "openai",
2246+
model: "gpt-4",
2247+
temperature: 0.7,
2248+
cache: true,
2249+
}
2250+
"#;
2251+
let program = parse(input).unwrap();
2252+
if let TopLevel::AiModel(m) = &program.items[0] {
2253+
assert_eq!(m.name.name, "Classifier");
2254+
assert_eq!(m.attributes.len(), 4);
2255+
} else {
2256+
panic!("Expected ai_model");
2257+
}
2258+
}
2259+
22322260
#[test]
22332261
fn test_prompt_decl() {
22342262
let input = r#"prompt greeting { "Hello, {name}!" }"#;

0 commit comments

Comments
 (0)