Skip to content

Commit 7dc455a

Browse files
committed
test(mutation): Introduce mutation-based test coverage
1 parent bcaa86f commit 7dc455a

7 files changed

Lines changed: 227 additions & 7 deletions

File tree

‎.github/workflows/mutants.yml‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
name: Mutation Testing
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- ".cargo/*.toml"
7+
- ".github/workflows/mutants.yml"
8+
- "Cargo.*"
9+
- "src/**"
10+
- "tests/**"
11+
12+
env:
13+
CARGO_TERM_COLOR: always
14+
15+
permissions:
16+
contents: read
17+
pull-requests: write
18+
19+
jobs:
20+
mutants:
21+
name: Mutation Testing
22+
runs-on: ubuntu-latest
23+
steps:
24+
- name: Checkout code
25+
uses: actions/checkout@v6
26+
with:
27+
fetch-depth: 0
28+
29+
- name: Install Rust
30+
uses: dtolnay/rust-toolchain@stable
31+
32+
- name: Cache dependencies
33+
uses: Swatinem/rust-cache@v2
34+
35+
- name: Install cargo-mutants
36+
uses: taiki-e/install-action@v2
37+
with:
38+
tool: cargo-mutants
39+
40+
- name: Relative diff
41+
run: |
42+
git branch -av
43+
git diff origin/${{ github.base_ref }}.. | tee git.diff
44+
45+
- name: Run cargo-mutants
46+
run: cargo mutants --no-shuffle --all-features --in-diff git.diff --in-place
47+
continue-on-error: true
48+
49+
- name: Check mutants report
50+
id: report
51+
run: |
52+
if [ -f mutants.out/outcomes.json ]; then
53+
echo "exists=true" >> "$GITHUB_OUTPUT"
54+
else
55+
echo "exists=false" >> "$GITHUB_OUTPUT"
56+
fi
57+
58+
- name: Comment on PR
59+
if: steps.report.outputs.exists == 'true' && (github.event.pull_request.head.repo.full_name == github.repository)
60+
uses: actions/github-script@v8
61+
with:
62+
script: |
63+
const fs = require('fs');
64+
const report = JSON.parse(fs.readFileSync('mutants.out/outcomes.json', 'utf8'));
65+
66+
const counts = {
67+
caught: Number(report.caught ?? 0),
68+
missed: Number(report.missed ?? 0),
69+
timeout: Number(report.timeout ?? 0),
70+
unviable: Number(report.unviable ?? 0),
71+
};
72+
73+
const missed = (report.outcomes ?? [])
74+
.filter(o => o.summary === 'MissedMutant' && o.scenario?.Mutant)
75+
.map(o => {
76+
const mutant = o.scenario.Mutant;
77+
const functionName = mutant.function?.function_name ?? mutant.name;
78+
const replacement = mutant.replacement ?? 'unknown replacement';
79+
return `${functionName}: ${replacement}`;
80+
});
81+
82+
const totalMutants = Number(report.total_mutants ?? 0);
83+
const testable = counts.caught + counts.missed;
84+
const score = testable > 0 ? (counts.caught / testable * 100).toFixed(1) : 'N/A';
85+
86+
let body = '## 🧬 Mutation Testing Results\n\n';
87+
body += 'Scope: PR diff (`--in-diff`)\n\n';
88+
body += '| Metric | Count |\n|--------|-------|\n';
89+
body += `| Caught | ${counts.caught} |\n`;
90+
body += `| Missed | ${counts.missed} |\n`;
91+
body += `| Timeout | ${counts.timeout} |\n`;
92+
body += `| Unviable | ${counts.unviable} |\n`;
93+
body += `| **Mutation Score** | **${score}%** |\n\n`;
94+
95+
if (totalMutants === 0) {
96+
body += 'No mutants were generated for the PR diff. This is expected for test-only or non-Rust changes.\n';
97+
} else if (missed.length > 0) {
98+
body += '<details><summary>Missed mutants (test gaps)</summary>\n\n```\n';
99+
body += missed.join('\n');
100+
body += '\n```\n\n</details>\n';
101+
} else {
102+
body += 'All mutants in the PR diff were caught by tests.\n';
103+
}
104+
105+
const marker = '## 🧬 Mutation Testing Results';
106+
const { data: comments } = await github.rest.issues.listComments({
107+
owner: context.repo.owner,
108+
repo: context.repo.repo,
109+
issue_number: context.issue.number,
110+
});
111+
const existing = comments.find(c => c.body?.includes(marker));
112+
113+
if (existing) {
114+
await github.rest.issues.updateComment({
115+
owner: context.repo.owner,
116+
repo: context.repo.repo,
117+
comment_id: existing.id,
118+
body,
119+
});
120+
} else {
121+
await github.rest.issues.createComment({
122+
owner: context.repo.owner,
123+
repo: context.repo.repo,
124+
issue_number: context.issue.number,
125+
body,
126+
});
127+
}
128+
129+
- name: No report notice
130+
if: steps.report.outputs.exists == 'false'
131+
run: echo "cargo-mutants did not produce mutants.out/outcomes.json; skipping PR comment and artifact upload."
132+
133+
- name: Upload mutants report
134+
if: steps.report.outputs.exists == 'true'
135+
uses: actions/upload-artifact@v7
136+
with:
137+
name: mutants-report
138+
path: mutants.out/
139+
retention-days: 14

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
# Generated by Cargo
22
target/
33

4+
# Mutation testing output
5+
mutants.out/
6+
47
.vscode/

‎src/ast.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,30 @@ mod helper_function_tests {
501501

502502
assert!(builtin_fn != func); // cross-variant catch-all arm
503503

504+
// PartialEq: Functions with different params are not equal
505+
let func_different_params =
506+
eval(&parse_scheme("(lambda (y) (+ y 1))").unwrap(), &mut env).unwrap();
507+
assert!(func != func_different_params);
508+
509+
// PartialEq: Functions with same params/body but different captured environments are not equal
510+
let mut env3 = create_global_env();
511+
eval(&parse_scheme("(define z 99)").unwrap(), &mut env3).unwrap();
512+
let func_different_env =
513+
eval(&parse_scheme("(lambda (x) (+ x 1))").unwrap(), &mut env3).unwrap();
514+
assert!(func != func_different_env);
515+
516+
// PartialEq: PrecompiledOps with same op but different args are not equal
517+
let precompiled_different_args = parse_scheme("(+ 3 4)").unwrap();
518+
assert!(precompiled != precompiled_different_args);
519+
520+
// PartialEq: Unspecified never equals non-Unspecified values
521+
assert!(Value::Unspecified != val(0));
522+
assert!(Value::Unspecified != val(false));
523+
assert!(Value::Unspecified != val(""));
524+
assert!(Value::Unspecified != nil());
525+
assert!(val(0) != Value::Unspecified);
526+
assert!(val(false) != Value::Unspecified);
527+
504528
// Error Display: (error, expected_substring)
505529
let error_cases: Vec<(crate::Error, &str)> = vec![
506530
(

‎src/builtinops.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,10 @@ mod tests {
674674
&& (add_ref.op_kind != if_ref.op_kind)
675675
&& (*add_ref == add_ref.clone())
676676
);
677+
678+
// BuiltinOp::eq: different builtins are not equal
679+
let sub_ref = find_scheme_op("-").unwrap();
680+
assert!(add_ref != sub_ref);
677681
}
678682

679683
/// Macro to create test cases, invoking builtins via the registry.

‎src/evaluator.rs‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,11 +1089,18 @@ mod tests {
10891089
("(+ 1 (car \"not-a-list\"))", Error),
10901090
("(if (not 42) 1 2)", Error),
10911091
// Test lambda parameter errors
1092-
("(lambda (x x) x)", Error), // Duplicate params
1093-
("(lambda \"not-a-list\" 42)", Error), // Invalid params
1092+
("(lambda (x x) x)", Error), // Duplicate params
1093+
(
1094+
"(lambda \"not-a-list\" 42)",
1095+
SpecificError("must be a list"),
1096+
), // Invalid params
1097+
("(lambda 42 x)", SpecificError("must be a list")), // Non-list params
10941098
// Test define errors
1095-
("(define 123 42)", Error), // Invalid var name
1096-
("(define \"not-symbol\" 42)", Error), // Invalid var name
1099+
("(define 123 42)", SpecificError("define requires a symbol")),
1100+
(
1101+
"(define \"not-symbol\" 42)",
1102+
SpecificError("define requires a symbol"),
1103+
),
10971104
// === ERROR CASES ===
10981105
// Unbound variables
10991106
(

‎src/jsonlogic.rs‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,8 @@ mod tests {
512512
r#"{"123invalid": [1, 2]}"#,
513513
SpecificError("Invalid operator name"),
514514
),
515+
// Invalid var names should be rejected
516+
(r#"{"var": "123"}"#, SpecificError("valid symbol")),
515517
// Design validation tests - operations intentionally rejected/different
516518
(
517519
r#"{"unknown_not": [true]}"#,
@@ -803,10 +805,38 @@ mod tests {
803805
"{label} should fail conversion"
804806
);
805807
}
808+
}
806809

807-
// Non-symbol list converts to JSON array
808-
let list_val = Value::List(vec![Value::Number(1), Value::Number(2)]);
809-
assert_eq!(ast_to_jsonlogic(&list_val).unwrap(), "[1,2]");
810+
#[test]
811+
fn test_ast_to_jsonlogic_roundtrip() {
812+
// AST values that should convert to specific JSON representations
813+
let roundtrip_cases: Vec<(Value, &str)> = vec![
814+
// Non-symbol list converts to JSON array
815+
(
816+
Value::List(vec![Value::Number(1), Value::Number(2)]),
817+
"[1,2]",
818+
),
819+
// Empty list converts to empty JSON array
820+
(Value::List(vec![]), "[]"),
821+
// PrecompiledOp "list" roundtrips to JSON array
822+
(parse_scheme("(list 1 2 3)").unwrap(), "[1,2,3]"),
823+
// Unprecompiled Value::List with Symbol("list") also roundtrips to JSON array
824+
(
825+
Value::List(vec![
826+
Value::Symbol("list".into()),
827+
Value::Number(4),
828+
Value::Number(5),
829+
]),
830+
"[4,5]",
831+
),
832+
];
833+
for (value, expected_json) in &roundtrip_cases {
834+
assert_eq!(
835+
ast_to_jsonlogic(value).unwrap(),
836+
*expected_json,
837+
"Roundtrip failed for {value:?}"
838+
);
839+
}
810840
}
811841

812842
/// Helper function to test AST equivalence and roundtrip (shared by Identical and IdenticalWithEvalError)

‎src/scheme.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,8 @@ mod tests {
695695
("(not #f)", precompiled_op("not", vec![val(false)])),
696696
// Test nested arity errors are also caught
697697
("(list (not) 42)", SpecificError("ArityError")),
698+
// Arity error nested inside unprecompiled list (unknown-fn isn't a builtin, so outer remains Value::List)
699+
("(unknown-fn (if #t 1))", SpecificError("ArityError")),
698700
// ===== PARSE-TIME SYNTAX ERRORS =====
699701
// Unclosed parentheses - should contain parse error information
700702
("(+ 1 (- 2", SpecificError("ParseError")),
@@ -737,6 +739,12 @@ mod tests {
737739
")".repeat(MAX_PARSE_DEPTH)
738740
);
739741
let deep_quotes_at_limit = format!("{}a", "'".repeat(MAX_PARSE_DEPTH));
742+
// Longhand (quote ...) nesting exercises the depth tracking in parse_list's quote path
743+
let deep_longhand_quote_at_limit = format!(
744+
"{}a{}",
745+
"(quote ".repeat(MAX_PARSE_DEPTH),
746+
")".repeat(MAX_PARSE_DEPTH)
747+
);
740748

741749
let depth_test_cases = vec![
742750
// At/over limit should fail at parse time with specific error
@@ -748,6 +756,11 @@ mod tests {
748756
deep_quotes_at_limit.as_str(),
749757
SpecificError("Invalid syntax"),
750758
),
759+
// Longhand (quote ...) nesting should also be caught
760+
(
761+
deep_longhand_quote_at_limit.as_str(),
762+
SpecificError("Invalid syntax"),
763+
),
751764
];
752765

753766
run_parse_tests(depth_test_cases);

0 commit comments

Comments
 (0)