Skip to main content

supercov_engine/
assertion_coverage.rs

1//! Assertions coverage: which executed statements a test would catch breaking.
2//!
3//! A statement counts as asserted when changing it makes at least one passing
4//! test that executes it fail. The change follows the statement: an `if` has its
5//! condition inverted (each direction), `return x` returns undefined, a
6//! declaration's value becomes undefined, an expression point evaluates to
7//! undefined, and any other statement is skipped. Imports, declarations without
8//! a value, and function, class and type declarations are not assessed.
9//!
10//! Supercov does not decide this itself. For each test it renders what the test
11//! ran (its code, its helpers, and the source it executed) and asks Jev, one
12//! yes/no question per statement, whether the test would fail. This module owns
13//! the population, the change rules and the rendering; the CLI owns the requests,
14//! the answer cache and the output. What Jev sees for one statement and one test
15//! is also the cache key, so an answer is reused only when the question would be
16//! asked with exactly the same text.
17use crate::coverage_analysis::PointKind;
18use crate::coverage_report::CoverageReport;
19use oxc_allocator::Allocator;
20use oxc_ast::ast::{
21    Declaration, ExportDefaultDeclarationKind, Expression, Statement as JsStatement,
22};
23use oxc_ast_visit::{Visit, walk};
24use oxc_parser::Parser;
25use oxc_span::{GetSpan, SourceType};
26use serde::Serialize;
27use serde_json::{Value, json};
28use sha2::{Digest, Sha256};
29use std::collections::{BTreeMap, BTreeSet};
30use std::path::{Path, PathBuf};
31
32mod go;
33mod jvm;
34mod ruby;
35mod rust_source;
36
37/// Statements starting on each line: their text and change (`None`: not
38/// assessed).
39type Starts = BTreeMap<usize, Vec<(String, Option<Change>)>>;
40
41pub const MODEL: &str = "jev-1.13.0";
42/// Tests asked about a statement before it may be called unchecked, then the
43/// wider set asked before it is (measured: more tests remove false "unchecked"
44/// flags, and 25 tests was no better than 15).
45pub const FIRST_TESTS: usize = 5;
46pub const MORE_TESTS: usize = 15;
47/// Asked last, when none of those is judged to fail: the tests most likely
48/// written for the statement.
49pub const LAST_TESTS: usize = 3;
50/// The most tests one statement is asked of.
51pub const MAX_TESTS: usize = MORE_TESTS + LAST_TESTS;
52pub const MAX_QUESTIONS: usize = 150;
53/// A statement is asserted when some test's answer is at least this.
54pub const THRESHOLD: f64 = 0.5;
55const HEAD_LINES: usize = 10;
56const HELPER_CHARS: usize = 3000;
57/// The budget for data files a test names (Ruby spec support fixtures),
58/// beside the helpers': the cases a data-driven test checks are its asserts.
59const FIXTURE_CHARS: usize = 12000;
60const CONTEXT_LINES: usize = 0;
61const WINDOW_LINES: usize = 4;
62/// How far either side of a statement that is in no function its file is
63/// shown.
64const OUTSIDE_A_FUNCTION: usize = 25;
65const CODE_CHARS: usize = 10000;
66const BODY_LINES: usize = 150;
67
68pub const INSTRUCTIONS: &str = "Each question describes exactly one change to the program, made alone. The test passes on the code as written and executes the changed line. Question: with that change, does this test fail (an assertion fails, test or helper code throws, the test times out, or the process crashes)? Follow what the changed line computes to what the test checks; do not guess from names. In `code_run`, lines marked ▶ start statements this test ran.";
69
70fn runtime(runner: &str) -> &'static str {
71    match runner {
72        "node:test" => {
73            "node:test; a test past its timeout fails; after hooks still run. The tests spawn the program as child processes; an unhandled promise rejection ends a Node process."
74        }
75        "jest" => {
76            "Jest; a test past its timeout (default 5000 ms) fails; an unhandled error during the run fails it; jest.fn() returns undefined unless given an implementation."
77        }
78        "ava" => {
79            "AVA; a failed t assertion, an uncaught error or a rejected promise the test returns fails it; a test past its timeout (default 10 s) fails; a test with no assertion fails unless failWithoutAssertions is off; a file's tests run concurrently unless declared with test.serial."
80        }
81        "tap" => {
82            "node-tap; a failed t assertion, an uncaught error or a rejected promise a subtest returns fails it; a subtest past its timeout (default 30 s) fails; a parent fails when a subtest does; each test file runs as a process of its own."
83        }
84        "mocha" => {
85            "Mocha; a test past its timeout (default 2000 ms) fails; an uncaught error, a rejected promise it returns or an error passed to done fails it; beforeEach hooks run before each test."
86        }
87        "pytest" => {
88            "pytest; a failing assert or an uncaught exception fails the test; there is no timeout unless pytest-timeout is configured; fixtures from conftest.py run before the test; a Mock or MagicMock returns another mock unless given a return value."
89        }
90        "rspec" => {
91            "RSpec; a failed expectation or an uncaught exception fails the example; there is no timeout; before hooks and let blocks run for each example; a double raises on messages it was not told to expect, and allow(...).to receive returns nil unless given a value."
92        }
93        "minitest" => {
94            "Minitest; a failed assertion or an uncaught exception fails the test; there is no timeout; setup runs before each test; a stubbed method returns the value it was given."
95        }
96        "test-unit" => {
97            "test-unit; a failed assertion or an uncaught exception fails the test; there is no timeout; setup runs before each test."
98        }
99        "cucumber" => {
100            "Cucumber; a step that raises (a failed expectation included) fails the scenario; a step with no definition leaves it undefined, not passed; Before hooks run for each scenario."
101        }
102        "go-test" => {
103            "go test; t.Error, t.Fatal and a panic fail the test; a test past the -timeout (default 10 minutes) fails; subtests run inside their parent; a nil pointer dereference panics. A test catches a changed value only where one of its checks compares it, directly or through what it feeds: a byte, field or result that no check reads can change while the test still passes."
104        }
105        "rust-libtest" | "rust-nextest" | "nextest" => {
106            "Rust tests; a panic fails the test (a failed assert!, assert_eq!, unwrap or expect included) unless it is marked #[should_panic], which then fails when nothing panics; there is no timeout."
107        }
108        "rustdoc" => {
109            "Rust doctests; the code block is compiled and run as its own program, and a panic fails it; a block marked no_run is only compiled and should_panic fails when nothing panics."
110        }
111        "junit-platform" => {
112            "JUnit; a failed assertion or an uncaught exception fails the test (an expected one inside assertThrows aside); @BeforeEach runs before each test; there is no timeout unless one is set; a Mockito mock returns null, 0, false or an empty collection unless stubbed."
113        }
114        "testng" => {
115            "TestNG; a failed assertion or an uncaught exception fails the test; @BeforeMethod runs before each test; there is no timeout unless one is set; a Mockito mock returns null, 0, false or an empty collection unless stubbed."
116        }
117        "unittest" => {
118            "unittest; a failing assert* method or an uncaught exception fails the test; there is no timeout; setUp runs before each test; a Mock or MagicMock returns another mock unless given a return value."
119        }
120        _ => {
121            "Vitest; a test past its timeout (default 5000 ms) fails; an unhandled error during the run fails it; vi.fn() returns undefined unless given an implementation."
122        }
123    }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
127#[serde(rename_all = "camelCase")]
128pub enum Change {
129    /// `if`: the condition inverted, each direction asked separately.
130    Invert,
131    /// `return x`: returns undefined without evaluating `x`.
132    ReturnUndefined,
133    /// A declaration with a value: the value becomes undefined.
134    ValueUndefined,
135    /// An expression point that is not a statement (a JSX expression).
136    ExpressionUndefined,
137    /// Anything else: the statement is skipped.
138    Skip,
139    /// A boolean literal returned or declared: it becomes the other one.
140    /// `false` becoming undefined changes nothing a truthiness check reads,
141    /// so that change could never be caught.
142    Flip,
143    /// A TypeScript `throw`: it throws a bare `new Error()` instead. Deleting
144    /// it seldom compiles: a throw usually guards the narrowing the next line
145    /// relies on (`if ('error' in result) throw ...; return result.result`
146    /// is TS2339 without it). Keeping the throw keeps the types, and asks
147    /// whether any test checks which error comes out.
148    BareError,
149}
150
151/// The languages whose statements and tests this module can read.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum Language {
154    JavaScript,
155    Python,
156    Ruby,
157    Go,
158    Rust,
159    /// Java and Kotlin, told apart by file extension.
160    Jvm,
161}
162
163impl Language {
164    /// Whether a request shows, of the code the test ran, the function each
165    /// asked statement is in and nothing else, and leaves the test's helper
166    /// files out. Measured for these two on eight projects against the
167    /// outcome of applying each change (2026-10-13): 28 to 81% of the tokens,
168    /// and 451 wrong verdicts where the fuller request gave 457. The other
169    /// languages have not been measured and are asked as before.
170    fn asks_within_the_function(self) -> bool {
171        matches!(self, Language::JavaScript | Language::Python)
172    }
173
174    /// The run manifest's language name, when it is one of these.
175    pub fn from_manifest(name: &str) -> Option<Self> {
176        match name {
177            "javascript" => Some(Self::JavaScript),
178            "python" => Some(Self::Python),
179            "ruby" => Some(Self::Ruby),
180            "go" => Some(Self::Go),
181            "rust" => Some(Self::Rust),
182            "jvm" => Some(Self::Jvm),
183            _ => None,
184        }
185    }
186    fn comment(self, line: &str) -> bool {
187        let t = line.trim_start();
188        match self {
189            Self::Python | Self::Ruby => t.starts_with('#'),
190            Self::JavaScript | Self::Go | Self::Rust | Self::Jvm => {
191                t.starts_with("//")
192                    || t.starts_with("/*")
193                    || t.starts_with("*/")
194                    || t == "*"
195                    || t.starts_with("* ")
196                    || t.starts_with("*\t")
197            }
198        }
199    }
200    /// The value a removed value becomes, as the questions name it.
201    fn nothing(self) -> &'static str {
202        match self {
203            Self::JavaScript => "undefined",
204            Self::Python => "None",
205            Self::Ruby => "nil",
206            Self::Go => "the zero value",
207            Self::Rust => "`Default::default()`",
208            Self::Jvm => "the default value (null, or 0 or false for a primitive)",
209        }
210    }
211}
212
213/// What separates a test's name from the groups it sits in.
214fn separator(language: Language, runner: &str) -> &'static str {
215    match (language, runner) {
216        (Language::JavaScript, _) => " > ",
217        (Language::Python, _) => "::",
218        (_, "rspec") => ":",
219        (_, "minitest" | "test-unit" | "junit-platform" | "testng") => "#",
220        (_, "go-test") => "/",
221        (_, "rustdoc") => " - ",
222        _ => "::",
223    }
224}
225
226#[derive(Debug, Clone)]
227pub struct Statement {
228    pub file: String,
229    pub line: usize,
230    pub text: String,
231    pub change: Change,
232    /// The value that replaces the statement's where the language chooses one
233    /// by type (Rust), rather than a stand-in for nothing.
234    pub replacement: Option<String>,
235    /// Indexes into `Population::tests`: the passing tests that executed it.
236    pub tests: Vec<usize>,
237}
238
239#[derive(Debug, Clone)]
240pub struct TestRef {
241    pub id: String,
242    pub file: String,
243    pub name: String,
244    pub runner: String,
245}
246
247pub struct Population {
248    pub root: PathBuf,
249    pub language: Language,
250    pub statements: Vec<Statement>,
251    pub tests: Vec<TestRef>,
252    /// Per test: the statement lines it ran, by file.
253    ran: Vec<BTreeMap<String, BTreeSet<usize>>>,
254    /// Files holding assessed statements; a test's own helpers are the files
255    /// it imports that are not among these.
256    source_files: BTreeSet<String>,
257    /// The project's Java and Kotlin files, for JVM tests and their imports.
258    jvm_files: Vec<String>,
259    /// Files read once: every request and every cache key reads the same text.
260    files: std::sync::Mutex<BTreeMap<String, Option<std::sync::Arc<str>>>>,
261    /// Whether a test file contains a text fragment, by (file, fragment).
262    quotes: std::sync::Mutex<std::collections::HashMap<(String, String), bool>>,
263}
264
265/// The executed statements of a run and the tests that ran them. `sources` are
266/// the run's source files, verified against the run.
267/// What assessing a run reads from its analysis: the passing view's tests,
268/// and its measured statements and executed decisions with the tests that ran
269/// them. Publication stores it, so an assessment does not analyse the run's
270/// evidence again (4.4 s of supergateway's 7.6 s of planning).
271#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)]
272pub struct AssessmentInput {
273    pub tests: Vec<InputTest>,
274    pub points: Vec<InputPoint>,
275    pub decisions: Vec<InputPoint>,
276}
277
278#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)]
279pub struct InputTest {
280    pub id: String,
281    pub file: Option<String>,
282    pub name: String,
283    pub role: String,
284    pub runner: String,
285}
286
287/// A statement or decision, with the tests that ran it as indexes into
288/// `AssessmentInput::tests`.
289#[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)]
290pub struct InputPoint {
291    pub file: String,
292    pub line: usize,
293    pub source: String,
294    pub tests: Vec<u32>,
295}
296
297pub fn assessment_input(report: &CoverageReport) -> AssessmentInput {
298    let view = &report.filters.passed;
299    let tests = view
300        .tests
301        .iter()
302        .map(|test| InputTest {
303            id: test.id.clone(),
304            file: test.file.clone(),
305            name: test.name.clone(),
306            role: test.role.clone(),
307            runner: test.provenance.runner.clone(),
308        })
309        .collect::<Vec<_>>();
310    let index = tests
311        .iter()
312        .enumerate()
313        .map(|(i, test)| (test.id.as_str(), i as u32))
314        .collect::<BTreeMap<_, _>>();
315    let indexes = |ids: &[crate::interned::Id]| {
316        ids.iter()
317            .filter_map(|id| index.get(id.as_str()).copied())
318            .collect::<Vec<_>>()
319    };
320    let points = view
321        .points
322        .iter()
323        .filter(|p| p.measured && p.meta.kind == PointKind::Statement)
324        .map(|p| InputPoint {
325            file: p.meta.file.clone(),
326            line: p.meta.line,
327            source: p.meta.source.clone(),
328            tests: indexes(&p.tests),
329        })
330        .collect();
331    let decisions = view
332        .decisions
333        .iter()
334        .filter(|d| d.executed)
335        .map(|d| InputPoint {
336            file: d.meta.file.clone(),
337            line: d.meta.line,
338            source: d.meta.source.clone(),
339            tests: indexes(&d.tests),
340        })
341        .collect();
342    AssessmentInput {
343        tests,
344        points,
345        decisions,
346    }
347}
348
349pub fn population(
350    root: &Path,
351    report: &CoverageReport,
352    sources: &BTreeMap<String, String>,
353    language: Language,
354) -> Result<Population, String> {
355    population_from(root, &assessment_input(report), sources, language)
356}
357
358pub fn population_from(
359    root: &Path,
360    input: &AssessmentInput,
361    sources: &BTreeMap<String, String>,
362    language: Language,
363) -> Result<Population, String> {
364    let mut tests = Vec::new();
365    // JVM tests are named by class; their file is found by its path.
366    let jvm_files = if language == Language::Jvm {
367        project_files(root, &["java", "kt"])
368    } else {
369        Vec::new()
370    };
371    // Input test index -> population test index.
372    let mut index = BTreeMap::<u32, usize>::new();
373    for (position, test) in input.tests.iter().enumerate() {
374        let file = match test.file.as_ref().filter(|f| !f.is_empty()) {
375            Some(file) => file.clone(),
376            None if language == Language::Jvm => {
377                let found = jvm::class_paths(&test.name)
378                    .iter()
379                    .find_map(|suffix| jvm_files.iter().find(|f| ends_with_path(f, suffix)));
380                match found {
381                    Some(file) => file.clone(),
382                    None => continue,
383                }
384            }
385            None => continue,
386        };
387        if test.role != "test" {
388            continue;
389        }
390        index.insert(position as u32, tests.len());
391        tests.push(TestRef {
392            id: test.id.clone(),
393            file,
394            name: test.name.clone(),
395            runner: test.runner.clone(),
396        });
397    }
398    let mut changes = BTreeMap::<String, BTreeMap<usize, Vec<(String, Option<Change>)>>>::new();
399    let mut replacements = BTreeMap::<String, rust_source::Replacements>::new();
400    let signatures = if language == Language::Rust {
401        rust_source::signatures(
402            sources
403                .iter()
404                .filter(|(file, _)| file.ends_with(".rs"))
405                .map(|(file, source)| (file.as_str(), source.as_str())),
406        )
407    } else {
408        rust_source::Signatures::default()
409    };
410    let mut statements = Vec::new();
411    let mut seen = BTreeSet::new();
412    // Python and Ruby record an `elif`/`elsif`, and Go, Rust and the JVM
413    // languages may record an `else if`, as a decision only: its condition is
414    // taken after every statement, where no statement starts on its line.
415    let executed = input
416        .points
417        .iter()
418        .map(|p| (&p.file, p.line, &p.source, &p.tests, false));
419    let elifs = input
420        .decisions
421        .iter()
422        .filter(|_| language != Language::JavaScript)
423        .map(|d| (&d.file, d.line, &d.source, &d.tests, true));
424    for (file, line, text, point_tests, decision) in executed.chain(elifs) {
425        let key = (file.clone(), line);
426        if !seen.insert(key) {
427            continue; // the first statement on a line, as the audit reads it
428        }
429        let ids = point_tests
430            .iter()
431            .filter_map(|position| index.get(position).copied())
432            .collect::<Vec<_>>();
433        if ids.is_empty() {
434            continue;
435        }
436        if language == Language::Rust && rust_source::test_path(file) {
437            continue;
438        }
439        let Some(source) = sources.get(file) else {
440            continue;
441        };
442        let lines = changes
443            .entry(file.clone())
444            .or_insert_with(|| match language {
445                Language::JavaScript => statement_starts(file, source),
446                Language::Python => python_statement_starts(source),
447                Language::Ruby => ruby::statement_starts(source),
448                Language::Go => go::statement_starts(source),
449                Language::Rust => rust_source::statement_starts(source),
450                Language::Jvm => jvm::statement_starts(file, source),
451            });
452        let Some(change) = change_at(lines, line, text, language) else {
453            continue;
454        };
455        if decision && change != Change::Invert {
456            continue;
457        }
458        let valued = matches!(change, Change::ReturnUndefined | Change::ValueUndefined);
459        let replacement = match language {
460            Language::Rust if valued => {
461                let first = prefix(text.lines().next().unwrap_or("").trim(), 30);
462                let first = first.trim_end_matches(';');
463                replacements
464                    .entry(file.clone())
465                    .or_insert_with(|| rust_source::replacements(file, source, &signatures))
466                    .get(&line)
467                    .and_then(|all| all.iter().find(|(t, _)| t.starts_with(first)))
468                    .map(|(_, r)| r.clone())
469            }
470            // Go: the line the change turns it into, zero values written out
471            // from the result types. (Writing the JVM's `null` out the same
472            // way measured no better on commons-cli and moved the share away
473            // from the truth, so the JVM keeps its description.)
474            Language::Go if valued => go::rewrite(source, line, change),
475            _ => None,
476        };
477        // A replacement that writes the statement out as it already is --
478        // `return 0, nil` with zero values, `count := 0` -- is no change.
479        if replacement
480            .as_deref()
481            .is_some_and(|written| same_code(written, text))
482        {
483            continue;
484        }
485        statements.push(Statement {
486            file: file.clone(),
487            line,
488            text: text.clone(),
489            change,
490            replacement,
491            tests: ids,
492        });
493    }
494    let mut ran = vec![BTreeMap::<String, BTreeSet<usize>>::new(); tests.len()];
495    for s in &statements {
496        for &t in &s.tests {
497            ran[t].entry(s.file.clone()).or_default().insert(s.line);
498        }
499    }
500    let source_files = statements.iter().map(|s| s.file.clone()).collect();
501    Ok(Population {
502        root: root.to_path_buf(),
503        language,
504        statements,
505        tests,
506        ran,
507        source_files,
508        jvm_files,
509        files: Default::default(),
510        quotes: Default::default(),
511    })
512}
513
514/// Whether two lines of code are the same but for spacing and a final `;`.
515fn same_code(a: &str, b: &str) -> bool {
516    let squash = |code: &str| {
517        code.trim()
518            .trim_end_matches(';')
519            .chars()
520            .filter(|c| !c.is_whitespace())
521            .collect::<String>()
522    };
523    squash(a) == squash(b)
524}
525
526/// The words of a name or a line of code: runs of letters and digits split at
527/// camelCase and at anything else, lowercased, short and filler words left out.
528fn name_words(text: &str) -> BTreeSet<String> {
529    const FILLER: [&str; 13] = [
530        "test", "tests", "the", "and", "for", "with", "get", "set", "self", "from", "not", "does",
531        "should",
532    ];
533    let mut words = BTreeSet::new();
534    let chars = text.chars().collect::<Vec<_>>();
535    let mut word = String::new();
536    let mut flush = |word: &mut String| {
537        let w = word.to_lowercase();
538        if w.len() > 2 && !FILLER.contains(&w.as_str()) {
539            words.insert(w);
540        }
541        word.clear();
542    };
543    for (i, &c) in chars.iter().enumerate() {
544        if !c.is_ascii_alphanumeric() {
545            flush(&mut word);
546            continue;
547        }
548        let previous = i.checked_sub(1).map(|p| chars[p]);
549        let next = chars.get(i + 1).copied();
550        // `getHTTPResponse`: a word starts at a capital after a lowercase
551        // letter, and at the last capital of a run before a lowercase one.
552        let starts = c.is_ascii_uppercase()
553            && (previous.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit())
554                || (previous.is_some_and(|p| p.is_ascii_uppercase())
555                    && next.is_some_and(|n| n.is_ascii_lowercase())));
556        if starts {
557            flush(&mut word);
558        }
559        word.push(c);
560    }
561    flush(&mut word);
562    words
563}
564
565/// Every statement starting on each line, outermost first, with its text and
566/// the change the audit applies to it (`None`: not assessed).
567fn statement_starts(file: &str, source: &str) -> BTreeMap<usize, Vec<(String, Option<Change>)>> {
568    let allocator = Allocator::default();
569    let source_type = SourceType::from_path(file).unwrap_or_else(|_| SourceType::tsx());
570    let parsed = Parser::new(&allocator, source, source_type).parse();
571    let starts = line_starts(source);
572    struct Collector<'s> {
573        source: &'s str,
574        starts: &'s [usize],
575        typescript: bool,
576        out: BTreeMap<usize, Vec<(String, Option<Change>)>>,
577    }
578    impl<'a> Visit<'a> for Collector<'_> {
579        fn visit_statement(&mut self, statement: &JsStatement<'a>) {
580            let span = statement.span();
581            let line = line_of(self.starts, span.start as usize);
582            let text = self
583                .source
584                .get(span.start as usize..span.end as usize)
585                .unwrap_or("")
586                .to_owned();
587            self.out.entry(line).or_default().push((
588                text,
589                match statement {
590                    JsStatement::ThrowStatement(_) if self.typescript => Some(Change::BareError),
591                    _ => change_of(statement),
592                },
593            ));
594            walk::walk_statement(self, statement);
595        }
596    }
597    let mut collector = Collector {
598        source,
599        starts: &starts,
600        typescript: source_type.is_typescript(),
601        out: BTreeMap::new(),
602    };
603    collector.visit_program(&parsed.program);
604    collector.out
605}
606
607/// `undefined` or `void 0`: returning undefined instead is the same code.
608/// What TypeScript wraps a value in to give it a type (`undefined as T`)
609/// leaves it the same value.
610fn is_undefined(expression: &Expression) -> bool {
611    match expression {
612        Expression::Identifier(identifier) => identifier.name == "undefined",
613        Expression::UnaryExpression(unary) => {
614            unary.operator == oxc_syntax::operator::UnaryOperator::Void
615        }
616        Expression::TSAsExpression(cast) => is_undefined(&cast.expression),
617        Expression::TSSatisfiesExpression(cast) => is_undefined(&cast.expression),
618        Expression::TSTypeAssertion(cast) => is_undefined(&cast.expression),
619        Expression::TSNonNullExpression(cast) => is_undefined(&cast.expression),
620        Expression::ParenthesizedExpression(inner) => is_undefined(&inner.expression),
621        _ => false,
622    }
623}
624
625fn change_of(statement: &JsStatement) -> Option<Change> {
626    Some(match statement {
627        JsStatement::IfStatement(_) => Change::Invert,
628        // `return undefined` returning undefined is the same code; skipping
629        // the return is the change that can show.
630        JsStatement::ReturnStatement(r) => match &r.argument {
631            Some(argument) if is_undefined(argument) => Change::Skip,
632            Some(Expression::BooleanLiteral(_)) => Change::Flip,
633            Some(_) => Change::ReturnUndefined,
634            None => Change::Skip,
635        },
636        JsStatement::VariableDeclaration(d) => {
637            if let [only] = d.declarations.as_slice()
638                && matches!(only.init, Some(Expression::BooleanLiteral(_)))
639            {
640                Change::Flip
641            } else if d.declarations.iter().any(|d| d.init.is_some()) {
642                Change::ValueUndefined
643            } else {
644                return None;
645            }
646        }
647        // A constructor cannot skip `super(...)`: the result does not compile
648        // (TS2377), and in JavaScript it throws before anything is observed.
649        JsStatement::ExpressionStatement(e)
650            if matches!(&e.expression, Expression::CallExpression(call)
651                if matches!(call.callee, Expression::Super(_))) =>
652        {
653            return None;
654        }
655        JsStatement::ExportNamedDeclaration(e) => match &e.declaration {
656            Some(Declaration::VariableDeclaration(d))
657                if d.declarations.iter().any(|d| d.init.is_some()) =>
658            {
659                Change::ValueUndefined
660            }
661            _ => return None,
662        },
663        JsStatement::ExportDefaultDeclaration(e) => match &e.declaration {
664            ExportDefaultDeclarationKind::FunctionDeclaration(_)
665            | ExportDefaultDeclarationKind::ClassDeclaration(_)
666            | ExportDefaultDeclarationKind::TSInterfaceDeclaration(_) => return None,
667            _ => Change::Skip,
668        },
669        JsStatement::ImportDeclaration(_)
670        | JsStatement::ExportAllDeclaration(_)
671        | JsStatement::FunctionDeclaration(_)
672        | JsStatement::ClassDeclaration(_)
673        | JsStatement::TSTypeAliasDeclaration(_)
674        | JsStatement::TSInterfaceDeclaration(_)
675        | JsStatement::TSEnumDeclaration(_)
676        | JsStatement::TSModuleDeclaration(_)
677        | JsStatement::TSGlobalDeclaration(_)
678        | JsStatement::TSImportEqualsDeclaration(_)
679        | JsStatement::TSExportAssignment(_)
680        | JsStatement::TSNamespaceExportDeclaration(_) => return None,
681        _ => Change::Skip,
682    })
683}
684
685/// The change for the measured statement on `line` whose text is `text`: the
686/// first statement starting there with that text, or an expression point when
687/// no statement starts there (a JSX expression is measured but not a statement).
688fn change_at(
689    starts: &BTreeMap<usize, Vec<(String, Option<Change>)>>,
690    line: usize,
691    text: &str,
692    language: Language,
693) -> Option<Change> {
694    let first = prefix(text.lines().next().unwrap_or("").trim(), 30);
695    let found = starts.get(&line).and_then(|all| {
696        all.iter().find(|(t, _)| {
697            let bare = t
698                .strip_prefix("export ")
699                .map(|t| t.strip_prefix("default ").unwrap_or(t))
700                .unwrap_or(t);
701            bare.starts_with(first) || t.starts_with(first)
702        })
703    });
704    match (found, language) {
705        (Some((_, change)), _) => *change,
706        (None, Language::JavaScript) => Some(Change::ExpressionUndefined),
707        // Elsewhere every statement is its own point; one no statement
708        // matches is not assessed rather than guessed at.
709        (None, _) => None,
710    }
711}
712
713/// Every Python statement starting on each line, outermost first, with its
714/// text and change: `if` inverted, `return x` returning None, an assignment's
715/// value becoming None, anything else skipped; imports, definitions, `pass`,
716/// `global`/`nonlocal` and bare strings (docstrings) are not assessed.
717fn python_statement_starts(source: &str) -> BTreeMap<usize, Vec<(String, Option<Change>)>> {
718    use ruff_python_ast::{
719        Expr, Stmt,
720        visitor::{Visitor, walk_stmt},
721    };
722    use ruff_text_size::Ranged;
723    struct Collector<'s> {
724        source: &'s str,
725        starts: Vec<usize>,
726        out: BTreeMap<usize, Vec<(String, Option<Change>)>>,
727    }
728    impl<'a> Visitor<'a> for Collector<'_> {
729        fn visit_stmt(&mut self, stmt: &'a Stmt) {
730            let change = match stmt {
731                Stmt::If(_) => Some(Change::Invert),
732                Stmt::Return(r) => Some(match r.value.as_deref() {
733                    // `return None` returning None is the same code.
734                    Some(Expr::NoneLiteral(_)) | None => Change::Skip,
735                    Some(Expr::BooleanLiteral(_)) => Change::Flip,
736                    Some(_) => Change::ReturnUndefined,
737                }),
738                Stmt::Assign(a)
739                    if a.targets.len() == 1
740                        && matches!(a.value.as_ref(), Expr::BooleanLiteral(_)) =>
741                {
742                    Some(Change::Flip)
743                }
744                Stmt::Assign(_) => Some(Change::ValueUndefined),
745                Stmt::AnnAssign(a)
746                    if matches!(a.value.as_deref(), Some(Expr::BooleanLiteral(_))) =>
747                {
748                    Some(Change::Flip)
749                }
750                Stmt::AnnAssign(a) => a.value.as_ref().map(|_| Change::ValueUndefined),
751                Stmt::Import(_)
752                | Stmt::ImportFrom(_)
753                | Stmt::FunctionDef(_)
754                | Stmt::ClassDef(_)
755                | Stmt::Pass(_)
756                | Stmt::Global(_)
757                | Stmt::Nonlocal(_)
758                | Stmt::TypeAlias(_)
759                | Stmt::IpyEscapeCommand(_) => None,
760                Stmt::Expr(e) if matches!(e.value.as_ref(), Expr::StringLiteral(_)) => None,
761                _ => Some(Change::Skip),
762            };
763            let range = stmt.range();
764            let (start, end) = (range.start().to_usize(), range.end().to_usize());
765            let line = line_of(&self.starts, start);
766            let text = self.source.get(start..end).unwrap_or("").to_owned();
767            self.out.entry(line).or_default().push((text, change));
768            // An `elif` is an `if` of its own, as JavaScript's `else if` is.
769            if let Stmt::If(s) = stmt {
770                // Its point is its condition, as coverage records it.
771                for test in s.elif_else_clauses.iter().filter_map(|c| c.test.as_ref()) {
772                    let start = test.range().start().to_usize();
773                    let text = self.source.get(start..test.range().end().to_usize());
774                    self.out
775                        .entry(line_of(&self.starts, start))
776                        .or_default()
777                        .push((text.unwrap_or("").to_owned(), Some(Change::Invert)));
778                }
779            }
780            walk_stmt(self, stmt);
781        }
782    }
783    let Ok(parsed) = ruff_python_parser::parse_module(source) else {
784        return BTreeMap::new();
785    };
786    let mut collector = Collector {
787        source,
788        starts: line_starts(source),
789        out: BTreeMap::new(),
790    };
791    for stmt in &parsed.syntax().body {
792        collector.visit_stmt(stmt);
793    }
794    collector.out
795}
796
797fn prefix(text: &str, chars: usize) -> &str {
798    match text.char_indices().nth(chars) {
799        Some((i, _)) => &text[..i],
800        None => text,
801    }
802}
803
804/// The lines of each function that lies in no other, with the statement that
805/// declares it (`export const seal = (key) => {`) and its decorators. A
806/// callback written inside a function belongs to it. First and last line,
807/// from one.
808fn outermost_functions(language: Language, file: &str, source: &str) -> Vec<(usize, usize)> {
809    let starts = line_starts(source);
810    let lines = |from: usize, to: usize| {
811        (
812            line_of(&starts, from),
813            line_of(&starts, to.saturating_sub(1).max(from)),
814        )
815    };
816    let mut spans = match language {
817        Language::JavaScript => {
818            let allocator = Allocator::default();
819            let source_type = SourceType::from_path(file).unwrap_or_else(|_| SourceType::tsx());
820            let parsed = Parser::new(&allocator, source, source_type).parse();
821            #[derive(Default)]
822            struct Functions {
823                // The statement, or the member of a class, a function is
824                // written in: what stands directly in the file, a block or a
825                // class body.
826                within: Option<(u32, u32)>,
827                found: Vec<(u32, u32)>,
828            }
829            impl Functions {
830                fn inside(&mut self, span: (u32, u32), walk: impl FnOnce(&mut Self)) {
831                    let outer = self.within.replace(span);
832                    walk(self);
833                    self.within = outer;
834                }
835                fn function(&mut self, own: oxc_span::Span) {
836                    self.found.push(self.within.unwrap_or((own.start, own.end)));
837                }
838            }
839            fn from_decorators(
840                span: oxc_span::Span,
841                decorators: &[oxc_ast::ast::Decorator<'_>],
842            ) -> (u32, u32) {
843                (
844                    decorators
845                        .first()
846                        .map_or(span.start, |first| first.span.start.min(span.start)),
847                    span.end,
848                )
849            }
850            impl<'a> Visit<'a> for Functions {
851                fn visit_program(&mut self, program: &oxc_ast::ast::Program<'a>) {
852                    for statement in &program.body {
853                        let span = statement.span();
854                        self.inside((span.start, span.end), |this| {
855                            this.visit_statement(statement)
856                        });
857                    }
858                }
859                fn visit_block_statement(&mut self, block: &oxc_ast::ast::BlockStatement<'a>) {
860                    for statement in &block.body {
861                        let span = statement.span();
862                        self.inside((span.start, span.end), |this| {
863                            this.visit_statement(statement)
864                        });
865                    }
866                }
867                fn visit_ts_module_block(&mut self, block: &oxc_ast::ast::TSModuleBlock<'a>) {
868                    for statement in &block.body {
869                        let span = statement.span();
870                        self.inside((span.start, span.end), |this| {
871                            this.visit_statement(statement)
872                        });
873                    }
874                }
875                fn visit_method_definition(&mut self, it: &oxc_ast::ast::MethodDefinition<'a>) {
876                    self.inside(from_decorators(it.span, &it.decorators), |this| {
877                        walk::walk_method_definition(this, it)
878                    });
879                }
880                fn visit_property_definition(&mut self, it: &oxc_ast::ast::PropertyDefinition<'a>) {
881                    self.inside(from_decorators(it.span, &it.decorators), |this| {
882                        walk::walk_property_definition(this, it)
883                    });
884                }
885                fn visit_accessor_property(&mut self, it: &oxc_ast::ast::AccessorProperty<'a>) {
886                    self.inside(from_decorators(it.span, &it.decorators), |this| {
887                        walk::walk_accessor_property(this, it)
888                    });
889                }
890                fn visit_static_block(&mut self, it: &oxc_ast::ast::StaticBlock<'a>) {
891                    self.inside((it.span.start, it.span.end), |this| {
892                        walk::walk_static_block(this, it)
893                    });
894                }
895                // Not walked into: what is inside a function is the function's.
896                fn visit_function(
897                    &mut self,
898                    it: &oxc_ast::ast::Function<'a>,
899                    _flags: oxc_syntax::scope::ScopeFlags,
900                ) {
901                    if it.body.is_some() {
902                        self.function(it.span);
903                    }
904                }
905                fn visit_arrow_function_expression(
906                    &mut self,
907                    it: &oxc_ast::ast::ArrowFunctionExpression<'a>,
908                ) {
909                    self.function(it.span);
910                }
911            }
912            let mut functions = Functions::default();
913            functions.visit_program(&parsed.program);
914            functions
915                .found
916                .into_iter()
917                .map(|(from, to)| lines(from as usize, to as usize))
918                .collect::<Vec<_>>()
919        }
920        Language::Python => {
921            use ruff_python_ast::{
922                Stmt,
923                visitor::{Visitor, walk_stmt},
924            };
925            use ruff_text_size::Ranged;
926            struct Functions(Vec<(usize, usize)>);
927            impl<'a> Visitor<'a> for Functions {
928                fn visit_stmt(&mut self, stmt: &'a Stmt) {
929                    match stmt {
930                        Stmt::FunctionDef(function) => {
931                            let from = function
932                                .decorator_list
933                                .first()
934                                .map_or(function.start(), |first| {
935                                    first.start().min(function.start())
936                                });
937                            self.0.push((from.to_usize(), function.end().to_usize()));
938                        }
939                        _ => walk_stmt(self, stmt),
940                    }
941                }
942            }
943            let Ok(parsed) = ruff_python_parser::parse_module(source) else {
944                return Vec::new();
945            };
946            let mut functions = Functions(Vec::new());
947            for stmt in &parsed.syntax().body {
948                functions.visit_stmt(stmt);
949            }
950            functions
951                .0
952                .into_iter()
953                .map(|(from, to)| lines(from, to))
954                .collect()
955        }
956        _ => Vec::new(),
957    };
958    spans.sort_unstable();
959    spans.dedup();
960    spans
961}
962
963/// How many lines a deleted statement is named by, when its question names
964/// all of them ("all 4 lines of it (lines 133 to 136)").
965fn whole_statement(statement: &Statement) -> Option<usize> {
966    let lines = statement.text.lines().count();
967    let word = statement
968        .text
969        .lines()
970        .next()
971        .unwrap_or("")
972        .trim()
973        .trim_end_matches(';')
974        .trim();
975    (statement.change == Change::Skip
976        && lines > 1
977        && !matches!(word, "break" | "continue" | "next"))
978    .then_some(lines)
979}
980
981fn line_starts(source: &str) -> Vec<usize> {
982    std::iter::once(0)
983        .chain(source.match_indices('\n').map(|(i, _)| i + 1))
984        .collect()
985}
986
987fn line_of(starts: &[usize], offset: usize) -> usize {
988    starts.partition_point(|&s| s <= offset)
989}
990
991fn numbered(lines: &[&str]) -> String {
992    lines
993        .iter()
994        .enumerate()
995        .map(|(i, l)| format!("{:5} {l}", i + 1))
996        .collect::<Vec<_>>()
997        .join("\n")
998}
999
1000/// The text a statement writes out: the fixed parts of its string literals,
1001/// six characters or longer with a letter in them (`- Headers:` from
1002/// `` `  - Headers: ${describe(headers)}` ``). A test that quotes one is the
1003/// likeliest to assert what the statement produces.
1004fn quoted_fragments(text: &str) -> Vec<String> {
1005    let mut out = Vec::new();
1006    let mut chars = text.char_indices().peekable();
1007    while let Some((start, quote)) = chars.next() {
1008        if !matches!(quote, '\'' | '"' | '`') {
1009            continue;
1010        }
1011        let mut end = None;
1012        while let Some((at, c)) = chars.next() {
1013            if c == '\\' {
1014                chars.next();
1015            } else if c == quote {
1016                end = Some(at);
1017                break;
1018            }
1019        }
1020        let Some(end) = end else { break };
1021        let body = &text[start + quote.len_utf8()..end];
1022        // The fixed text only: what sits inside `${...}`, `#{...}` or an
1023        // f-string's `{...}` is an expression, not output.
1024        let mut pieces = vec![String::new()];
1025        let mut depth = 0usize;
1026        for c in body.chars() {
1027            match c {
1028                '{' => {
1029                    depth += 1;
1030                    pieces.push(String::new());
1031                }
1032                '}' if depth > 0 => depth -= 1,
1033                '$' | '#' | '%' if depth == 0 => pieces.push(String::new()),
1034                _ if depth == 0 => pieces.last_mut().expect("one piece").push(c),
1035                _ => {}
1036            }
1037        }
1038        for piece in pieces {
1039            let piece = piece.trim();
1040            if piece.len() >= 6 && piece.chars().filter(|c| c.is_alphabetic()).count() >= 3 {
1041                out.push(piece.to_owned());
1042            }
1043        }
1044    }
1045    out.sort();
1046    out.dedup();
1047    out
1048}
1049
1050fn stem(path: &str) -> String {
1051    Path::new(path)
1052        .file_name()
1053        .and_then(|n| n.to_str())
1054        .unwrap_or("")
1055        .split('.')
1056        .next()
1057        .unwrap_or("")
1058        .to_lowercase()
1059}
1060
1061fn describe(name: &str, separator: &str) -> String {
1062    let parts = name.split(separator).collect::<Vec<_>>();
1063    parts[..parts.len().saturating_sub(1)].join(separator)
1064}
1065
1066impl Population {
1067    /// Read these files from here rather than the checkout: a run's sources
1068    /// as they were assessed, once the checkout has moved on.
1069    pub fn with_files(self, files: BTreeMap<String, String>) -> Self {
1070        {
1071            let mut cache = self
1072                .files
1073                .lock()
1074                .expect("the file cache is only read and filled");
1075            for (file, text) in files {
1076                cache.insert(file, Some(text.into()));
1077            }
1078        }
1079        self
1080    }
1081
1082    /// Every file read so far, as it was read.
1083    pub fn files_read(&self) -> BTreeMap<String, String> {
1084        self.files
1085            .lock()
1086            .expect("the file cache is only read and filled")
1087            .iter()
1088            .filter_map(|(file, text)| text.as_ref().map(|t| (file.clone(), t.to_string())))
1089            .collect()
1090    }
1091
1092    fn read(&self, file: &str) -> Option<std::sync::Arc<str>> {
1093        let mut files = self
1094            .files
1095            .lock()
1096            .expect("the file cache is only read and filled");
1097        files
1098            .entry(file.to_owned())
1099            .or_insert_with(|| {
1100                std::fs::read_to_string(self.root.join(file))
1101                    .ok()
1102                    .map(Into::into)
1103            })
1104            .clone()
1105    }
1106
1107    /// Whether `test`'s file contains one of these fragments.
1108    fn quotes(&self, test: usize, fragments: &[String]) -> bool {
1109        let file = &self.tests[test].file;
1110        fragments.iter().any(|fragment| {
1111            let key = (file.clone(), fragment.clone());
1112            if let Some(&known) = self
1113                .quotes
1114                .lock()
1115                .expect("the quote cache is only read and filled")
1116                .get(&key)
1117            {
1118                return known;
1119            }
1120            let found = self
1121                .read(file)
1122                .is_some_and(|text| text.contains(fragment.as_str()));
1123            self.quotes
1124                .lock()
1125                .expect("the quote cache is only read and filled")
1126                .insert(key, found);
1127            found
1128        })
1129    }
1130
1131    /// The tests to ask about a statement, in order: tests whose file quotes
1132    /// text the statement produces first, one per file; then tests in a file
1133    /// named like the source; then one per describe block, round-robin over
1134    /// files. Without the first rule, the alphabetically first files filled
1135    /// the sample: on supergateway 15 of 179 tests were asked about a log
1136    /// line, none of them the test that compares the whole startup log.
1137    pub fn order(&self, statement: usize, k: usize) -> Vec<usize> {
1138        let s = &self.statements[statement];
1139        let base = stem(&s.file);
1140        let key = |&t: &usize| {
1141            let test = &self.tests[t];
1142            (
1143                !Path::new(&test.file)
1144                    .file_name()
1145                    .and_then(|n| n.to_str())
1146                    .unwrap_or("")
1147                    .to_lowercase()
1148                    .contains(&base),
1149                test.file.clone(),
1150                test.name.clone(),
1151            )
1152        };
1153        let mut all = s.tests.clone();
1154        all.sort_by_key(key);
1155        if all.len() <= k {
1156            return all;
1157        }
1158        let mut groups = Vec::<((String, String), Vec<usize>)>::new();
1159        for &t in &all {
1160            let g = (
1161                self.tests[t].file.clone(),
1162                describe(
1163                    &self.tests[t].name,
1164                    separator(self.language, &self.tests[t].runner),
1165                ),
1166            );
1167            match groups.iter_mut().find(|(k, _)| *k == g) {
1168                Some((_, v)) => v.push(t),
1169                None => groups.push((g, vec![t])),
1170            }
1171        }
1172        let mut by_file = Vec::<(String, Vec<Vec<usize>>)>::new();
1173        for ((file, _), members) in groups {
1174            match by_file.iter_mut().find(|(f, _)| *f == file) {
1175                Some((_, v)) => v.push(members),
1176                None => by_file.push((file, vec![members])),
1177            }
1178        }
1179        by_file.sort_by_key(|(f, _)| {
1180            (
1181                !Path::new(f)
1182                    .file_name()
1183                    .and_then(|n| n.to_str())
1184                    .unwrap_or("")
1185                    .to_lowercase()
1186                    .contains(&base),
1187                f.clone(),
1188            )
1189        });
1190        let mut picked = Vec::new();
1191        let fragments = quoted_fragments(&s.text);
1192        if !fragments.is_empty() {
1193            let quoting = all
1194                .iter()
1195                .copied()
1196                .filter(|&t| self.quotes(t, &fragments))
1197                .collect::<Vec<_>>();
1198            let mut files = BTreeSet::new();
1199            let (first, rest): (Vec<usize>, Vec<usize>) = quoting
1200                .iter()
1201                .partition(|&&t| files.insert(self.tests[t].file.clone()));
1202            for t in first.into_iter().chain(rest) {
1203                if picked.len() < k {
1204                    picked.push(t);
1205                }
1206            }
1207        }
1208        let mut depth = 0;
1209        while picked.len() < k && by_file.iter().any(|(_, g)| g.len() > depth) {
1210            for (_, g) in &by_file {
1211                if depth < g.len() && picked.len() < k && !picked.contains(&g[depth][0]) {
1212                    picked.push(g[depth][0]);
1213                }
1214            }
1215            depth += 1;
1216        }
1217        for t in all {
1218            if picked.len() >= k {
1219                break;
1220            }
1221            if !picked.contains(&t) {
1222                picked.push(t);
1223            }
1224        }
1225        picked
1226    }
1227
1228    /// The tests to ask last, when none of `asked` was judged to fail: of the
1229    /// rest, those whose name shares the most words with the statement's
1230    /// line, and among those the tests that ran the least code.
1231    ///
1232    /// A statement hundreds of tests run is mostly run in passing, by
1233    /// end-to-end tests on their way somewhere else. The tests written for it
1234    /// tend to say so in their name (`corsOrigin: a false value` for
1235    /// `return corsOrigin(...)`) and to run little besides it. Asking a few
1236    /// more in the usual order found few of them; replacing some of the usual
1237    /// tests with these lost statements the usual tests catch.
1238    pub fn last_order(&self, statement: usize, asked: &[usize], k: usize) -> Vec<usize> {
1239        let spread = self.order(statement, MORE_TESTS);
1240        let position = |t: &usize| spread.iter().position(|x| x == t).unwrap_or(usize::MAX);
1241        let s = &self.statements[statement];
1242        let line = name_words(s.text.lines().next().unwrap_or(""));
1243        let shared = |t: usize| {
1244            let name = &self.tests[t].name;
1245            let name = name.rsplit("::").next().unwrap_or(name);
1246            name_words(name).intersection(&line).count()
1247        };
1248        let mut rest = s
1249            .tests
1250            .iter()
1251            .copied()
1252            .filter(|t| !asked.contains(t))
1253            .collect::<Vec<_>>();
1254        rest.sort_by_cached_key(|t| {
1255            (
1256                std::cmp::Reverse(shared(*t)),
1257                self.breadth(*t),
1258                position(t),
1259                *t,
1260            )
1261        });
1262        rest.truncate(k);
1263        rest
1264    }
1265
1266    /// How many statement lines a test ran.
1267    fn breadth(&self, test: usize) -> usize {
1268        self.ran[test].values().map(BTreeSet::len).sum()
1269    }
1270
1271    /// The test's code: the file's first lines and the test's own body, found
1272    /// by its title, comment lines left out.
1273    pub fn test_code(&self, test: usize) -> String {
1274        match self.language {
1275            Language::Python => return self.python_test_code(test),
1276            Language::JavaScript => {}
1277            _ => return self.located_test_code(test),
1278        }
1279        let t = &self.tests[test];
1280        let text = self.read(&t.file).unwrap_or_default();
1281        let lines = text.split('\n').collect::<Vec<_>>();
1282        let title = t.name.rsplit(" > ").next().unwrap_or(&t.name);
1283        let needle = prefix(title, 40);
1284        let start = (!needle.is_empty())
1285            .then(|| lines.iter().position(|l| l.contains(needle)))
1286            .flatten();
1287        let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1288        let head = (0..lines.len().min(HEAD_LINES))
1289            .filter(|&i| !self.language.comment(lines[i]))
1290            .map(fmt)
1291            .collect::<Vec<_>>();
1292        let Some(start) = start else {
1293            let rest = (30.min(lines.len())..230.min(lines.len())).map(fmt);
1294            return head
1295                .into_iter()
1296                .chain(std::iter::once("  ...".to_owned()))
1297                .chain(rest)
1298                .collect::<Vec<_>>()
1299                .join("\n");
1300        };
1301        let indent = lines[start].len() - lines[start].trim_start().len();
1302        let mut body = Vec::new();
1303        for (i, l) in lines
1304            .iter()
1305            .copied()
1306            .enumerate()
1307            .take(start + BODY_LINES)
1308            .skip(start)
1309        {
1310            if i > start && is_test_call(l) && l.len() - l.trim_start().len() <= indent {
1311                break;
1312            }
1313            if !self.language.comment(l) {
1314                body.push(fmt(i));
1315            }
1316        }
1317        let mut out = head;
1318        if start > HEAD_LINES {
1319            out.push("  ...".into());
1320        }
1321        out.extend(body);
1322        out.join("\n")
1323    }
1324
1325    /// A Ruby, Go, Rust or JVM test's code: the file's first lines, the lines
1326    /// that frame it (its class or groups, its annotations), and its body.
1327    fn located_test_code(&self, test: usize) -> String {
1328        let t = &self.tests[test];
1329        let text = self.read(&t.file).unwrap_or_default();
1330        let lines = text.split('\n').collect::<Vec<_>>();
1331        let located = match self.language {
1332            Language::Ruby => ruby::locate(&text, &lines, &t.runner, &t.name),
1333            Language::Go => go::locate(&lines, &t.name),
1334            Language::Rust => rust_source::locate(&lines, &t.name),
1335            _ => jvm::locate(&lines, &t.name),
1336        };
1337        let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1338        // Lines inside `/* ... */` (a license header, say) are comments too.
1339        let mut open = false;
1340        let commented = lines
1341            .iter()
1342            .map(|l| {
1343                let t = l.trim();
1344                let was = open;
1345                if self.language != Language::Ruby {
1346                    if !open && t.starts_with("/*") {
1347                        open = true;
1348                    }
1349                    if open && t.contains("*/") {
1350                        open = false;
1351                        return true;
1352                    }
1353                }
1354                was || open || self.language.comment(l)
1355            })
1356            .collect::<Vec<_>>();
1357        // The head: the file's first code lines (its package and imports).
1358        let head = (0..lines.len())
1359            .filter(|&i| !commented[i])
1360            .take(HEAD_LINES)
1361            .collect::<Vec<_>>();
1362        let head_end = head.last().map_or(0, |&i| i + 1);
1363        let mut out = head.iter().map(|&i| fmt(i)).collect::<Vec<_>>();
1364        let Some(located) = located else {
1365            out.push("  ...".into());
1366            out.extend((head_end..(head_end + 200).min(lines.len())).map(fmt));
1367            return out.join("\n");
1368        };
1369        let end = located
1370            .end
1371            .min(located.start + BODY_LINES)
1372            .min(lines.len().saturating_sub(1));
1373        let mut last = head_end;
1374        let mut shown = located.context.iter().copied().collect::<BTreeSet<_>>();
1375        shown.extend(located.start..=end);
1376        // What the test uses from its own file: a table it loops over, a
1377        // helper it calls, a `let` it reads.
1378        shown.extend(used_definitions(&lines, self.language, located.start, end));
1379        for i in shown {
1380            // A doctest lives in comments: its lines are the test.
1381            if i < last || (commented[i] && self.language != Language::Rust) {
1382                continue;
1383            }
1384            if i > last {
1385                out.push("  ...".into());
1386            }
1387            out.push(fmt(i));
1388            last = i + 1;
1389        }
1390        out.join("\n")
1391    }
1392
1393    /// A Python test's code: the file's first lines, the class holding the
1394    /// test, its decorators, and its body, found by the test function's name
1395    /// and ended by indentation.
1396    fn python_test_code(&self, test: usize) -> String {
1397        let t = &self.tests[test];
1398        let text = self.read(&t.file).unwrap_or_default();
1399        let lines = text.split('\n').collect::<Vec<_>>();
1400        let title = python_title(&t.name);
1401        let indent_of = |l: &str| l.len() - l.trim_start().len();
1402        let start = (!title.is_empty())
1403            .then(|| {
1404                lines.iter().position(|l| {
1405                    let t = l.trim_start();
1406                    t.starts_with(&format!("def {title}("))
1407                        || t.starts_with(&format!("async def {title}("))
1408                })
1409            })
1410            .flatten();
1411        let fmt = |i: usize| format!("{:5} {}", i + 1, lines[i]);
1412        let mut out = (0..lines.len().min(HEAD_LINES))
1413            .filter(|&i| !self.language.comment(lines[i]))
1414            .map(fmt)
1415            .collect::<Vec<_>>();
1416        let Some(start) = start else {
1417            out.push("  ...".into());
1418            out.extend((HEAD_LINES.min(lines.len())..(HEAD_LINES + 200).min(lines.len())).map(fmt));
1419            return out.join("\n");
1420        };
1421        let indent = indent_of(lines[start]);
1422        let mut first = start;
1423        while first > 0
1424            && lines[first - 1].trim_start().starts_with('@')
1425            && indent_of(lines[first - 1]) == indent
1426        {
1427            first -= 1;
1428        }
1429        let class = (indent > 0)
1430            .then(|| {
1431                (0..first).rev().find(|&i| {
1432                    lines[i].trim_start().starts_with("class ") && indent_of(lines[i]) < indent
1433                })
1434            })
1435            .flatten();
1436        for i in class.into_iter().chain(first..start) {
1437            if i >= HEAD_LINES {
1438                if out.last().is_some_and(|l| l != "  ...") {
1439                    out.push("  ...".into());
1440                }
1441                out.push(fmt(i));
1442            }
1443        }
1444        if start >= HEAD_LINES
1445            && out.last().is_some_and(|l| l != "  ...")
1446            && first == start
1447            && class.is_none()
1448        {
1449            out.push("  ...".into());
1450        }
1451        for (i, &l) in lines.iter().enumerate().skip(start).take(BODY_LINES) {
1452            if i > start && !l.trim().is_empty() && indent_of(l) <= indent {
1453                break;
1454            }
1455            if !self.language.comment(l) && i >= HEAD_LINES {
1456                out.push(fmt(i));
1457            }
1458        }
1459        out.join("\n")
1460    }
1461
1462    /// Test-side files the test imports (helpers, fixtures), cut to a budget
1463    /// that grows with the number of questions. None for JavaScript,
1464    /// TypeScript and Python (see `asks_within_the_function`): on
1465    /// supergateway they were a sixth of what was sent and changed 4 verdicts
1466    /// in 101, two each way.
1467    pub fn helpers(&self, test: usize, questions: usize) -> BTreeMap<String, String> {
1468        if self.language.asks_within_the_function() {
1469            return BTreeMap::new();
1470        }
1471        let cap = HELPER_CHARS.min(1500 + 400 * questions);
1472        let t = &self.tests[test];
1473        let text = self.read(&t.file).unwrap_or_default();
1474        let mut out = BTreeMap::new();
1475        let mut size = 0;
1476        let files = match self.language {
1477            Language::JavaScript | Language::Python => Vec::new(),
1478            Language::Ruby => ruby::helpers(&self.root, &t.file, &text),
1479            Language::Go => go::helpers(&self.root, &t.file, &text),
1480            Language::Rust => rust_source::helpers(&self.root, &t.file, &text),
1481            Language::Jvm => jvm::imports(&text)
1482                .iter()
1483                .filter_map(|import| {
1484                    jvm::class_paths(&import.replace('/', "."))
1485                        .iter()
1486                        .find_map(|suffix| {
1487                            self.jvm_files.iter().find(|f| ends_with_path(f, suffix))
1488                        })
1489                        .cloned()
1490                })
1491                .collect(),
1492        };
1493        for file in files {
1494            if out.contains_key(&file) {
1495                continue;
1496            }
1497            if self.source_files.contains(&file) || file.contains("node_modules") {
1498                continue;
1499            }
1500            let mut body = self.read(&file).map(|b| b.to_string()).unwrap_or_default();
1501            if size + body.len() > cap {
1502                let keep = cap.saturating_sub(size);
1503                let cut = body
1504                    .char_indices()
1505                    .map(|(i, _)| i)
1506                    .take_while(|&i| i <= keep)
1507                    .last()
1508                    .unwrap_or(0);
1509                body = format!("{}\n... (cut)", &body[..cut]);
1510            }
1511            size += body.len();
1512            out.insert(file, numbered(&body.split('\n').collect::<Vec<_>>()));
1513            if size >= cap {
1514                break;
1515            }
1516        }
1517        if self.language == Language::Ruby {
1518            let mut used = 0;
1519            for file in ruby::support_files(&self.root, &text) {
1520                if out.contains_key(&file) || used >= FIXTURE_CHARS {
1521                    continue;
1522                }
1523                let mut body = self.read(&file).map(|b| b.to_string()).unwrap_or_default();
1524                if used + body.len() > FIXTURE_CHARS {
1525                    let keep = FIXTURE_CHARS - used;
1526                    let cut = body
1527                        .char_indices()
1528                        .map(|(i, _)| i)
1529                        .take_while(|&i| i <= keep)
1530                        .last()
1531                        .unwrap_or(0);
1532                    body = format!("{}\n... (cut)", &body[..cut]);
1533                }
1534                used += body.len();
1535                out.insert(file, numbered(&body.split('\n').collect::<Vec<_>>()));
1536            }
1537        }
1538        out
1539    }
1540
1541    /// The source this test ran: every statement it executed marked ▶, four
1542    /// code lines around each asked statement, comment lines left out, within
1543    /// a budget that grows with the number of questions. Two lines around
1544    /// every executed statement and eight around each asked one, in a budget
1545    /// four times this, cost 28% more over 3,532 checked statements in 14
1546    /// projects and were no more accurate.
1547    ///
1548    /// In JavaScript, TypeScript and Python that is cut to the function each
1549    /// asked statement is in (see `asks_within_the_function`): what the test
1550    /// ran elsewhere in the file, and in other files, is left out.
1551    pub fn code_run(&self, test: usize, asked: &[usize]) -> BTreeMap<String, String> {
1552        let budget = CODE_CHARS.min(1000 + 400 * asked.len());
1553        let within = self.language.asks_within_the_function();
1554        let ran = &self.ran[test];
1555        let mut want = BTreeMap::<String, BTreeSet<usize>>::new();
1556        for &a in asked {
1557            want.entry(self.statements[a].file.clone())
1558                .or_default()
1559                .insert(self.statements[a].line);
1560        }
1561        let mut files = want.keys().cloned().collect::<Vec<_>>();
1562        let mut others = ran
1563            .iter()
1564            .filter(|(f, _)| !within && !want.contains_key(*f))
1565            .collect::<Vec<_>>();
1566        others.sort_by_key(|(f, lines)| (std::cmp::Reverse(lines.len()), (*f).clone()));
1567        files.extend(others.into_iter().map(|(f, _)| f.clone()));
1568        let empty = BTreeSet::new();
1569        let mut out = BTreeMap::new();
1570        let mut total = 0;
1571        for f in files {
1572            let Some(source) = self.read(&f) else {
1573                continue;
1574            };
1575            let text = source.split('\n').collect::<Vec<_>>();
1576            let r = ran.get(&f).unwrap_or(&empty);
1577            let code = (1..=text.len())
1578                .filter(|&i| !self.language.comment(text[i - 1]))
1579                .collect::<Vec<_>>();
1580            let pos = code
1581                .iter()
1582                .enumerate()
1583                .map(|(n, &i)| (i, n))
1584                .collect::<BTreeMap<_, _>>();
1585            let around = |l: usize, w: usize| -> Vec<usize> {
1586                match pos.get(&l) {
1587                    Some(&n) => code[n.saturating_sub(w)..(n + w + 1).min(code.len())].to_vec(),
1588                    None => (l.saturating_sub(w).max(1)..=(l + w).min(text.len())).collect(),
1589                }
1590            };
1591            let mut show = BTreeSet::new();
1592            for &l in r {
1593                show.extend(around(l, CONTEXT_LINES));
1594            }
1595            for &l in want.get(&f).unwrap_or(&empty) {
1596                show.extend(around(l, WINDOW_LINES));
1597            }
1598            // How far the file was going to be shown, for the line that says
1599            // how much of it is not.
1600            let end = show
1601                .iter()
1602                .rev()
1603                .copied()
1604                .find(|&i| i != 0 && i <= text.len() && !self.language.comment(text[i - 1]))
1605                .unwrap_or(0);
1606            if within {
1607                let functions = outermost_functions(self.language, &f, &source);
1608                let around =
1609                    |l: usize| (l.saturating_sub(OUTSIDE_A_FUNCTION), l + OUTSIDE_A_FUNCTION);
1610                let function = |l: usize| {
1611                    functions
1612                        .iter()
1613                        .copied()
1614                        .find(|&(from, to)| from <= l && l <= to)
1615                };
1616                // The lines an assessed statement of this file begins on.
1617                let begins = self
1618                    .statements
1619                    .iter()
1620                    .filter(|s| s.file == f)
1621                    .map(|s| s.line)
1622                    .collect::<BTreeSet<_>>();
1623                let mut kept = Vec::new();
1624                for statement in asked.iter().map(|&a| &self.statements[a]) {
1625                    if statement.file != f {
1626                        continue;
1627                    }
1628                    kept.push(function(statement.line).unwrap_or(around(statement.line)));
1629                    // A statement its question names by all its lines also
1630                    // keeps what lies near each line it goes on to, as the
1631                    // requests this was measured with did.
1632                    for l in (statement.line + 1)
1633                        ..statement.line + whole_statement(statement).unwrap_or(1)
1634                    {
1635                        kept.push(
1636                            begins
1637                                .contains(&l)
1638                                .then(|| function(l))
1639                                .flatten()
1640                                .unwrap_or(around(l)),
1641                        );
1642                    }
1643                }
1644                show.retain(|i| kept.iter().any(|&(from, to)| from <= *i && *i <= to));
1645            }
1646            let mut lines = Vec::new();
1647            let mut last = 0;
1648            for i in show {
1649                if i == 0 || i > text.len() || self.language.comment(text[i - 1]) {
1650                    continue;
1651                }
1652                if i != last + 1 {
1653                    lines.push(format!("        ... {} lines not shown", i - last - 1));
1654                }
1655                lines.push(format!(
1656                    "{}{:5} {}",
1657                    if r.contains(&i) { '▶' } else { ' ' },
1658                    i,
1659                    text[i - 1]
1660                ));
1661                last = i;
1662            }
1663            if within && last != 0 && end > last {
1664                lines.push(format!("        ... {} lines not shown", end - last));
1665            }
1666            let body = lines.join("\n");
1667            if !want.contains_key(&f) && total + body.len() > budget {
1668                continue;
1669            }
1670            total += body.len();
1671            out.insert(f, body);
1672        }
1673        out
1674    }
1675
1676    /// The questions for one statement: one per change (two for an `if`).
1677    pub fn questions(&self, statement: usize) -> Vec<String> {
1678        let s = &self.statements[statement];
1679        let line = s.text.lines().next().unwrap_or("").trim();
1680        let changes = match (s.change, &s.replacement) {
1681            (Change::ReturnUndefined | Change::ValueUndefined, Some(r))
1682                if self.language == Language::Rust =>
1683            {
1684                vec![rust_question(line, s.change, r)]
1685            }
1686            // The value is replaced after it is computed, as Go's mutation
1687            // testers do.
1688            (Change::ReturnUndefined, Some(r)) if self.language == Language::Go => {
1689                vec![format!(
1690                    "`{line}` becomes `{r}` (the original expression is still evaluated first)"
1691                )]
1692            }
1693            (Change::ValueUndefined, Some(r)) if self.language == Language::Go => {
1694                vec![format!(
1695                    "`{line}` becomes `{r}` (the original value is still computed first)"
1696                )]
1697            }
1698            _ => self.default_questions(s, line),
1699        };
1700        changes
1701            .into_iter()
1702            .map(|c| format!("{}:{}: {c}.", s.file, s.line))
1703            .collect()
1704    }
1705
1706    fn default_questions(&self, s: &Statement, line: &str) -> Vec<String> {
1707        match s.change {
1708            // Go and the JVM languages replace a value after it is computed,
1709            // as their mutation testers do: the expression still runs.
1710            Change::ReturnUndefined if self.language == Language::Go => vec![format!(
1711                "`{line}` returns the zero value of each result instead: nil for an error, pointer, slice, map or interface, 0, \"\" or false, or an empty struct (its expressions still run)"
1712            )],
1713            // A `when`, `switch` or `try` ending a function returns from its
1714            // branches.
1715            Change::ReturnUndefined
1716                if self.language == Language::Jvm
1717                    && s.text.contains("return")
1718                    && ["when", "switch", "try"].iter().any(|k| {
1719                        line.strip_prefix(k)
1720                            .is_some_and(|rest| rest.starts_with([' ', '(', '{']))
1721                    }) =>
1722            {
1723                vec![format!(
1724                    "every value `{line}` returns becomes {} instead (the returned expressions still run)",
1725                    self.language.nothing()
1726                )]
1727            }
1728            Change::ReturnUndefined if self.language == Language::Jvm => vec![format!(
1729                "`{line}` returns {} instead (its expression still runs)",
1730                self.language.nothing()
1731            )],
1732            Change::ReturnUndefined if matches!(self.language, Language::Ruby | Language::Rust) => {
1733                let nothing = match self.language {
1734                    Language::Rust
1735                        if ["Ok(", "Err(", "return Ok(", "return Err("]
1736                            .iter()
1737                            .any(|p| line.starts_with(p)) =>
1738                    {
1739                        "`Ok(Default::default())`"
1740                    }
1741                    other => other.nothing(),
1742                };
1743                vec![if line.starts_with("return") {
1744                    format!("`{line}` returns {nothing} instead (the expression is not evaluated)")
1745                } else {
1746                    // Often a call made for what it does, not what it gives
1747                    // back: saying only "the value becomes nil" hides that.
1748                    format!(
1749                        "`{line}` becomes {nothing}: it is not evaluated, so nothing it does happens (no call it makes, no value it stores), and the enclosing block or method produces {nothing}"
1750                    )
1751                }]
1752            }
1753            Change::ValueUndefined if self.language == Language::Go => vec![format!(
1754                "`{line}`: after it runs, the variables it assigns hold the zero value of their type instead"
1755            )],
1756            Change::ValueUndefined if self.language == Language::Jvm => vec![format!(
1757                "`{line}`: after it runs, the variable it assigns holds {} instead",
1758                self.language.nothing()
1759            )],
1760            Change::ValueUndefined if matches!(self.language, Language::Ruby | Language::Rust) => {
1761                let nothing = self.language.nothing();
1762                vec![match split_initializer(line) {
1763                    Some(head) => format!(
1764                        "`{line}`: the value after `{head}` becomes {nothing} (it is not evaluated)"
1765                    ),
1766                    None => format!("`{line}`: the assigned value becomes {nothing}"),
1767                }]
1768            }
1769            Change::ReturnUndefined if self.language == Language::Python => vec![format!(
1770                "`{line}` becomes `return None` (the expression is not evaluated)"
1771            )],
1772            Change::ReturnUndefined => vec![format!(
1773                "`{line}` becomes `return undefined;` (the expression is not evaluated)"
1774            )],
1775            Change::ValueUndefined if self.language == Language::Python => {
1776                vec![match split_initializer(line) {
1777                    Some(head) => {
1778                        format!("`{line}` becomes `{head} None` (the value is not evaluated)")
1779                    }
1780                    None => format!("`{line}`: the assigned value becomes None"),
1781                }]
1782            }
1783            Change::ValueUndefined => vec![match split_initializer(line) {
1784                Some(head) => format!(
1785                    "`{line}` becomes `{head} undefined` (the initializer is not evaluated)"
1786                ),
1787                None => format!("`{line}`: the declared value becomes undefined"),
1788            }],
1789            Change::Skip => {
1790                let word = line.trim_end_matches(';').trim();
1791                vec![match word {
1792                    "break" => format!(
1793                        "`{line}` is deleted: execution does not leave the enclosing loop or switch here, and carries on with what follows it"
1794                    ),
1795                    "continue" | "next" => format!(
1796                        "`{line}` is deleted: the rest of the loop body runs in this iteration"
1797                    ),
1798                    // A statement of several lines is shown by its first.
1799                    // Read as "`for x in items:` is deleted", only the header
1800                    // seemed to go: 22 of 100 such verdicts were wrong on six
1801                    // projects, 9 with all of it named.
1802                    _ if whole_statement(s).is_some() => {
1803                        let lines = s.text.lines().count();
1804                        format!(
1805                            "the whole statement starting `{line}` is deleted, all {lines} lines of it (lines {} to {}): none of it runs",
1806                            s.line,
1807                            s.line + lines - 1
1808                        )
1809                    }
1810                    // Python: what deleting a return or a raise lets happen.
1811                    // Of the skipped returns and raises of five projects, 61
1812                    // of 371 verdicts were wrong as "is deleted" and 44 so.
1813                    // (TypeScript returns were judged worse this way, 21 of
1814                    // 82 against 18, and keep the plain wording.)
1815                    _ if self.language == Language::Python
1816                        && (word == "return" || word.starts_with("return ")) =>
1817                    {
1818                        format!(
1819                            "`{line}` is deleted: the function does not return here, and execution carries on with whatever follows it"
1820                        )
1821                    }
1822                    _ if self.language == Language::Python
1823                        && (word == "raise" || word.starts_with("raise ")) =>
1824                    {
1825                        format!(
1826                            "`{line}` is deleted: no error is raised here, and execution carries on with whatever follows it"
1827                        )
1828                    }
1829                    _ => format!("`{line}` is deleted (it never runs)"),
1830                }]
1831            }
1832            Change::ExpressionUndefined => vec![format!(
1833                "the expression `{line}` evaluates to undefined instead (it is not evaluated)"
1834            )],
1835            Change::Flip => vec![format!("`{line}` becomes `{}`", flipped(line))],
1836            Change::BareError => vec![format!(
1837                "`{line}` throws `new Error()` instead: execution still stops here, but the error has no message and is not its own type"
1838            )],
1839            Change::Invert => {
1840                let c = match self.language {
1841                    Language::Python => python_condition(line),
1842                    Language::JavaScript => condition(line),
1843                    Language::Ruby => ruby::condition(line),
1844                    Language::Go => go::condition(line),
1845                    Language::Rust => rust_source::condition(line),
1846                    Language::Jvm => jvm::condition(line),
1847                };
1848                // A pattern match (`if let`) can be made to fail, not to
1849                // succeed: its bindings would have no value.
1850                if c.starts_with("let ") {
1851                    return vec![format!(
1852                        "`{line}`: whenever the pattern `{c}` matches it is treated as not matching (the branch it would enter is skipped)"
1853                    )];
1854                }
1855                vec![
1856                    format!(
1857                        "`{line}`: whenever the condition `{c}` is true it is treated as false (the branch it would enter is skipped)"
1858                    ),
1859                    format!(
1860                        "`{line}`: whenever the condition `{c}` is false it is treated as true (the branch is entered)"
1861                    ),
1862                ]
1863            }
1864        }
1865    }
1866
1867    /// The request for one test and the statements asked about it.
1868    pub fn request(&self, test: usize, asked: &[usize], model: &str) -> Value {
1869        let t = &self.tests[test];
1870        let mut questions = serde_json::Map::new();
1871        for (n, &a) in asked.iter().enumerate() {
1872            for (j, q) in self.questions(a).into_iter().enumerate() {
1873                questions.insert(
1874                    format!("q{n}_{j}"),
1875                    json!({"type": "noul", "instructions": {"task": q}, "criteria": {"true": "The test fails.", "false": "The test still passes."}}),
1876                );
1877            }
1878        }
1879        let mut state = json!({
1880            "instructions": INSTRUCTIONS, "runtime": runtime(&t.runner),
1881            "test": {"file": t.file, "name": t.name},
1882            "test_code": self.test_code(test),
1883            "code_run": self.code_run(test, asked)});
1884        if !self.language.asks_within_the_function() {
1885            state["helpers"] = json!(self.helpers(test, asked.len()));
1886        }
1887        json!({"model": model, "state": state, "questions": questions})
1888    }
1889
1890    /// What Jev sees for one statement asked of one test, line numbers aside:
1891    /// the same code renumbered is the same question. Equal keys, equal question.
1892    /// `salt` carries what changes every answer at once: the run's dependency
1893    /// and configuration fingerprints.
1894    pub fn pair_key(&self, statement: usize, test: usize, model: &str, salt: &str) -> String {
1895        let t = &self.tests[test];
1896        let parts = json!([
1897            model,
1898            salt,
1899            INSTRUCTIONS,
1900            runtime(&t.runner),
1901            true,
1902            &t.file,
1903            &t.name,
1904            strip_numbers(&self.test_code(test)),
1905            self.helpers(test, 1)
1906                .into_iter()
1907                .map(|(f, b)| (f, strip_numbers(&b)))
1908                .collect::<BTreeMap<_, _>>(),
1909            self.code_run(test, &[statement])
1910                .into_iter()
1911                .map(|(f, b)| (f, strip_numbers(&b)))
1912                .collect::<BTreeMap<_, _>>(),
1913            self.questions(statement)
1914                .iter()
1915                .map(|q| strip_location(q))
1916                .collect::<Vec<_>>(),
1917        ]);
1918        format!(
1919            "{:x}",
1920            Sha256::digest(serde_json::to_vec(&parts).expect("serializable key"))
1921        )
1922    }
1923}
1924
1925/// The line with its boolean literal turned into the other one: the last
1926/// `true`/`false` (`True`/`False` in Python) that stands as a word.
1927fn flipped(line: &str) -> String {
1928    // A trailing comment is not code: `let ready = false // true once open`
1929    // flipped the comment's `true`.
1930    let cut = comment_start(line);
1931    let (code, comment) = line.split_at(cut);
1932    format!("{}{comment}", flipped_code(code))
1933}
1934
1935/// Where a trailing `//` or `# ` comment starts, outside string literals; the
1936/// line's length when it has none. A bare `#` is code (a private field).
1937fn comment_start(line: &str) -> usize {
1938    let bytes = line.as_bytes();
1939    let mut quote = None;
1940    let mut i = 0;
1941    while i < bytes.len() {
1942        let c = bytes[i];
1943        match quote {
1944            Some(_) if c == b'\\' => i += 1,
1945            Some(q) if c == q => quote = None,
1946            Some(_) => {}
1947            None if matches!(c, b'\'' | b'"' | b'`') => quote = Some(c),
1948            None if c == b'/' && bytes.get(i + 1) == Some(&b'/') => return i,
1949            None if c == b'#' && bytes.get(i + 1).is_none_or(|n| *n == b' ') => return i,
1950            None => {}
1951        }
1952        i += 1;
1953    }
1954    line.len()
1955}
1956
1957fn flipped_code(line: &str) -> String {
1958    let words = [
1959        ("true", "false"),
1960        ("false", "true"),
1961        ("True", "False"),
1962        ("False", "True"),
1963    ];
1964    let found = words
1965        .iter()
1966        .filter_map(|(from, to)| {
1967            line.match_indices(from)
1968                .filter(|(at, _)| {
1969                    let before = line[..*at].chars().next_back();
1970                    let after = line[at + from.len()..].chars().next();
1971                    let word = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
1972                    !word(before) && !word(after)
1973                })
1974                .last()
1975                .map(|(at, _)| (at, *from, *to))
1976        })
1977        .max_by_key(|(at, _, _)| *at);
1978    match found {
1979        Some((at, from, to)) => format!("{}{to}{}", &line[..at], &line[at + from.len()..]),
1980        None => line.to_owned(),
1981    }
1982}
1983
1984/// A Rust return or assignment's change, as the line it becomes.
1985fn rust_question(line: &str, change: Change, replacement: &str) -> String {
1986    let repl = replacement.split_whitespace().collect::<Vec<_>>().join(" ");
1987    match change {
1988        Change::ReturnUndefined if line.starts_with("return") => {
1989            format!("`{line}` becomes `return {repl};`")
1990        }
1991        Change::ReturnUndefined => {
1992            format!("`{line}` becomes `{repl}` (it is the value the enclosing block produces)")
1993        }
1994        _ => match split_initializer(line) {
1995            Some(head) => format!("`{line}` becomes `{head} {repl};`"),
1996            None => format!("`{line}`: the value it assigns becomes `{repl}`"),
1997        },
1998    }
1999}
2000
2001/// Write files' text, compressed, to `path`.
2002pub fn save_snapshot(path: &Path, files: &BTreeMap<String, String>) -> Result<(), String> {
2003    use std::io::Write;
2004    let bytes = serde_json::to_vec(files).map_err(|e| e.to_string())?;
2005    let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2006    encoder.write_all(&bytes).map_err(|e| e.to_string())?;
2007    let compressed = encoder.finish().map_err(|e| e.to_string())?;
2008    let temp = path.with_extension("tmp");
2009    std::fs::write(&temp, compressed).map_err(|e| e.to_string())?;
2010    std::fs::rename(&temp, path).map_err(|e| e.to_string())
2011}
2012
2013/// Read files written by [`save_snapshot`].
2014pub fn load_snapshot(path: &Path) -> Option<BTreeMap<String, String>> {
2015    use std::io::Read;
2016    let compressed = std::fs::read(path).ok()?;
2017    let mut text = Vec::new();
2018    flate2::read::GzDecoder::new(compressed.as_slice())
2019        .read_to_end(&mut text)
2020        .ok()?;
2021    serde_json::from_slice(&text).ok()
2022}
2023
2024/// Where a test sits in its file: its first line, its last, and lines that
2025/// frame it (its class, groups or annotations), all zero-based.
2026struct Located {
2027    context: Vec<usize>,
2028    start: usize,
2029    end: usize,
2030}
2031
2032fn indent(line: &str) -> usize {
2033    line.len() - line.trim_start().len()
2034}
2035
2036/// The line closing the braces opened from `start` on (strings, characters
2037/// and line comments aside).
2038fn brace_end(lines: &[&str], start: usize) -> usize {
2039    let mut depth = 0i64;
2040    let mut opened = false;
2041    for (i, line) in lines.iter().enumerate().skip(start).take(BODY_LINES) {
2042        let mut chars = line.chars().peekable();
2043        let mut quote: Option<char> = None;
2044        while let Some(c) = chars.next() {
2045            match (quote, c) {
2046                (Some(_), '\\') => {
2047                    chars.next();
2048                }
2049                (Some(q), c) if c == q => quote = None,
2050                (Some(_), _) => {}
2051                (None, '"' | '`') => quote = Some(c),
2052                (None, '/') if chars.peek() == Some(&'/') => break,
2053                (None, '{') => {
2054                    depth += 1;
2055                    opened = true;
2056                }
2057                (None, '}') => depth -= 1,
2058                _ => {}
2059            }
2060        }
2061        if opened && depth <= 0 {
2062            return i;
2063        }
2064    }
2065    (start + BODY_LINES).min(lines.len()).saturating_sub(1)
2066}
2067
2068/// The `end` (or `}`) closing a Ruby block or method opened on `start`.
2069fn keyword_end(lines: &[&str], start: usize) -> usize {
2070    let first = lines[start].trim_end();
2071    if first.ends_with(" end") || first.ends_with('}') {
2072        return start;
2073    }
2074    let depth = indent(lines[start]);
2075    (start + 1..lines.len().min(start + BODY_LINES))
2076        .find(|&i| {
2077            let t = lines[i].trim_start();
2078            indent(lines[i]) == depth && (t == "end" || t.starts_with("end ") || t.starts_with('}'))
2079        })
2080        .unwrap_or((start + BODY_LINES).min(lines.len()).saturating_sub(1))
2081}
2082
2083const DEFINITION_LINES: usize = 120;
2084
2085/// The lines of the test file's own definitions that the test at
2086/// `start..=end` names, and those they name in turn, up to a budget.
2087fn used_definitions(lines: &[&str], language: Language, start: usize, end: usize) -> Vec<usize> {
2088    let words = |range: std::ops::RangeInclusive<usize>| {
2089        let mut out = BTreeSet::new();
2090        for i in range {
2091            let line = lines.get(i).copied().unwrap_or("");
2092            out.extend(
2093                line.split(|c: char| !(c.is_alphanumeric() || c == '_'))
2094                    .filter(|w| !w.is_empty())
2095                    .map(str::to_owned),
2096            );
2097        }
2098        out
2099    };
2100    let definitions = definitions(lines, language);
2101    let mut wanted = words(start..=end);
2102    let mut picked = BTreeSet::new();
2103    let mut budget = DEFINITION_LINES;
2104    for _ in 0..2 {
2105        let mut next = BTreeSet::new();
2106        for (name, a, b) in &definitions {
2107            let (a, b) = (*a, *b);
2108            if picked.contains(&a) || (a >= start && a <= end) || !wanted.contains(name) {
2109                continue;
2110            }
2111            let size = b - a + 1;
2112            if size > budget {
2113                continue;
2114            }
2115            budget -= size;
2116            picked.insert(a);
2117            next.extend(words(a..=b));
2118            picked.extend(a..=b);
2119        }
2120        wanted = next;
2121    }
2122    picked.into_iter().collect()
2123}
2124
2125/// A file's named definitions (functions, methods, table variables, RSpec
2126/// `let`/`subject`) with their first and last lines.
2127fn definitions(lines: &[&str], language: Language) -> Vec<(String, usize, usize)> {
2128    let ident = |s: &str| {
2129        s.chars()
2130            .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '?' || *c == '!')
2131            .collect::<String>()
2132    };
2133    let mut out = Vec::new();
2134    for (i, line) in lines.iter().enumerate() {
2135        let t = line.trim_start();
2136        let name = match language {
2137            Language::Go => t
2138                .strip_prefix("func ")
2139                .map(|r| match r.strip_prefix('(') {
2140                    Some(r) => r.split_once(')').map_or("", |(_, r)| r.trim_start()),
2141                    None => r,
2142                })
2143                .or_else(|| t.strip_prefix("var "))
2144                .or_else(|| t.strip_prefix("type "))
2145                .filter(|_| indent(line) == 0)
2146                .map(ident),
2147            Language::Rust => {
2148                let r = t.strip_prefix("pub ").unwrap_or(t);
2149                let r = r.strip_prefix("pub(crate) ").unwrap_or(r);
2150                ["fn ", "const ", "static ", "struct ", "enum "]
2151                    .iter()
2152                    .find_map(|k| r.strip_prefix(k))
2153                    .map(ident)
2154            }
2155            Language::Ruby => t
2156                .strip_prefix("def ")
2157                .map(|r| ident(r.strip_prefix("self.").unwrap_or(r)))
2158                .or_else(|| {
2159                    ["let(:", "let!(:", "subject(:"]
2160                        .iter()
2161                        .find_map(|k| t.strip_prefix(k))
2162                        .map(ident)
2163                })
2164                .or_else(|| {
2165                    (t.starts_with("subject ") || t.starts_with("subject{"))
2166                        .then(|| "subject".into())
2167                }),
2168            Language::Jvm => {
2169                if let Some(r) = t.split_once("fun ").filter(|(head, _)| !head.contains('(')) {
2170                    Some(ident(r.1.trim_start_matches('`')))
2171                } else if t.contains('(')
2172                    && !t.ends_with(';')
2173                    && !t.starts_with(['@', '}', '.', '/', '*', '+', '"'])
2174                    && ![
2175                        "return", "new ", "if", "for", "while", "switch", "catch", "else", "try",
2176                        "throw", "assert", "super", "this",
2177                    ]
2178                    .iter()
2179                    .any(|k| t.starts_with(k))
2180                {
2181                    let head = t.split('(').next().unwrap_or("");
2182                    let mut parts = head.split_whitespace().rev();
2183                    let name = parts.next().unwrap_or("");
2184                    (parts.next().is_some() && !head.contains('=')).then(|| ident(name))
2185                } else {
2186                    None
2187                }
2188            }
2189            _ => None,
2190        };
2191        let Some(name) = name.filter(|n| !n.is_empty()) else {
2192            continue;
2193        };
2194        let end = match language {
2195            Language::Ruby => keyword_end(lines, i),
2196            _ if line.contains('{')
2197                || lines
2198                    .get(i + 1)
2199                    .is_some_and(|n| n.trim_start().starts_with('{')) =>
2200            {
2201                brace_end(lines, i)
2202            }
2203            _ => i,
2204        };
2205        out.push((name, i, end));
2206    }
2207    out
2208}
2209
2210/// Whether `path` is `suffix` or ends with `/suffix`.
2211fn ends_with_path(path: &str, suffix: &str) -> bool {
2212    path.strip_suffix(suffix)
2213        .is_some_and(|head| head.is_empty() || head.ends_with('/'))
2214}
2215
2216/// The project's files with these extensions, as project-relative paths
2217/// (ignored files, such as build output, left out).
2218fn project_files(root: &Path, extensions: &[&str]) -> Vec<String> {
2219    let mut out = ignore::WalkBuilder::new(root)
2220        .build()
2221        .filter_map(Result::ok)
2222        .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
2223        .filter(|e| {
2224            e.path()
2225                .extension()
2226                .and_then(|x| x.to_str())
2227                .is_some_and(|x| extensions.contains(&x))
2228        })
2229        .filter_map(|e| {
2230            e.path()
2231                .strip_prefix(root)
2232                .ok()
2233                .map(|p| p.to_string_lossy().replace('\\', "/"))
2234        })
2235        .filter(|p| !p.starts_with(".supercov/"))
2236        .collect::<Vec<_>>();
2237    out.sort();
2238    out
2239}
2240
2241fn is_test_call(line: &str) -> bool {
2242    let t = line.trim_start();
2243    let rest = if let Some(r) = t.strip_prefix("it") {
2244        r
2245    } else if let Some(r) = t.strip_prefix("test") {
2246        r
2247    } else {
2248        return false;
2249    };
2250    let rest = if let Some(r) = rest.strip_prefix('.') {
2251        r.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_')
2252    } else {
2253        rest
2254    };
2255    rest.trim_start().starts_with('(')
2256}
2257
2258/// `const x = value` -> `const x =`; `None` when there is no plain `=`.
2259fn split_initializer(line: &str) -> Option<String> {
2260    let b = line.as_bytes();
2261    for i in 1..b.len() {
2262        if b[i] == b'='
2263            && !matches!(b[i - 1], b'=' | b'!' | b'<' | b'>')
2264            && !matches!(b.get(i + 1), Some(b'=') | Some(b'>'))
2265        {
2266            return Some(line[..=i].to_owned());
2267        }
2268    }
2269    None
2270}
2271
2272/// `if c:` -> `c`
2273/// A Python test's function name: pytest `file::Class::test[param]`,
2274/// unittest `module.Class.test`.
2275fn python_title(name: &str) -> &str {
2276    let last = name.rsplit("::").next().unwrap_or(name);
2277    let last = last.split('[').next().unwrap_or(last);
2278    last.rsplit('.').next().unwrap_or(last)
2279}
2280
2281fn python_condition(line: &str) -> String {
2282    let t = line.trim();
2283    let t = t
2284        .strip_prefix("elif")
2285        .or_else(|| t.strip_prefix("if"))
2286        .map(str::trim_start)
2287        .unwrap_or(t);
2288    t.strip_suffix(':')
2289        .map(str::trim_end)
2290        .unwrap_or(t)
2291        .to_owned()
2292}
2293
2294/// `if (c) {` -> `c`
2295fn condition(line: &str) -> String {
2296    let mut t = line.trim();
2297    if let Some(r) = t.strip_prefix('}') {
2298        let r = r.trim_start();
2299        if let Some(r) = r.strip_prefix("else") {
2300            t = r.trim_start();
2301        }
2302    }
2303    let Some(r) = t.strip_prefix("if") else {
2304        return line.to_owned();
2305    };
2306    let r = r.trim_start();
2307    let Some(r) = r.strip_prefix('(') else {
2308        return line.to_owned();
2309    };
2310    let r = r.trim_end();
2311    let r = r.strip_suffix('{').map(str::trim_end).unwrap_or(r);
2312    match r.strip_suffix(')') {
2313        Some(c) => c.to_owned(),
2314        None => line.to_owned(),
2315    }
2316}
2317
2318fn strip_numbers(text: &str) -> String {
2319    text.split('\n')
2320        .map(|l| {
2321            let (mark, rest) = match l.chars().next() {
2322                Some(c @ ('▶' | ' ')) => (Some(c), &l[c.len_utf8()..]),
2323                _ => (None, l),
2324            };
2325            let trimmed = rest.trim_start();
2326            let digits = trimmed.len()
2327                - trimmed
2328                    .trim_start_matches(|c: char| c.is_ascii_digit())
2329                    .len();
2330            if digits > 0 && trimmed[digits..].starts_with(' ') {
2331                format!(
2332                    "{}{}",
2333                    mark.map(String::from).unwrap_or_default(),
2334                    &trimmed[digits + 1..]
2335                )
2336            } else if let Some(n) = l
2337                .trim_start()
2338                .strip_prefix("... ")
2339                .and_then(|r| r.split_once(" lines not shown"))
2340            {
2341                let _ = n;
2342                "...".to_owned()
2343            } else {
2344                l.to_owned()
2345            }
2346        })
2347        .collect::<Vec<_>>()
2348        .join("\n")
2349}
2350
2351fn strip_location(question: &str) -> String {
2352    // `file:12: change` -> `file:: change`
2353    match question.split_once(": ") {
2354        Some((loc, rest)) => match loc.rsplit_once(':') {
2355            Some((file, line)) if line.chars().all(|c| c.is_ascii_digit()) => {
2356                format!("{file}:: {rest}")
2357            }
2358            _ => question.to_owned(),
2359        },
2360        None => question.to_owned(),
2361    }
2362}
2363
2364/// The project-relative name of `path`, `..` and `.` resolved, with `/`
2365/// between parts on every platform: a run names its files that way, and on
2366/// Windows a joined path had `\\` and named a helper no run had.
2367fn normalize(path: &Path) -> String {
2368    let mut out = Vec::new();
2369    for c in path.components() {
2370        match c {
2371            std::path::Component::ParentDir => {
2372                out.pop();
2373            }
2374            std::path::Component::CurDir => {}
2375            other => out.push(other.as_os_str().to_string_lossy().into_owned()),
2376        }
2377    }
2378    out.join("/")
2379}
2380
2381#[cfg(test)]
2382mod tests {
2383    #[test]
2384    fn a_replacement_that_is_the_statement_is_no_change() {
2385        assert!(super::same_code("return 0, nil", "return 0,  nil"));
2386        assert!(super::same_code("count := 0", "count := 0;"));
2387        assert!(!super::same_code("return 0, nil", "return n, nil"));
2388        let source = "package p\n\nfunc f(n int) (int, error) {\n\tif n > 0 {\n\t\treturn n, nil\n\t}\n\treturn 0, nil\n}\n";
2389        assert_eq!(
2390            super::go::rewrite(source, 7, Change::ReturnUndefined).as_deref(),
2391            Some("return 0, nil")
2392        );
2393    }
2394
2395    use super::*;
2396
2397    #[test]
2398    fn a_statement_many_tests_run_is_asked_of_the_tests_that_ran_little_else() {
2399        // Seventeen end-to-end tests pass through `total` and nine other
2400        // statements; three unit tests, in the file that sorts last, run
2401        // `total` alone. One test per file in file order never reaches the
2402        // unit tests within fifteen.
2403        let source = (0..10)
2404            .map(|n| format!("const v{n} = compute({n});"))
2405            .collect::<Vec<_>>()
2406            .join("\n");
2407        let test = |file: &str, name: &str| InputTest {
2408            id: format!("{file}::{name}"),
2409            file: Some(file.to_owned()),
2410            name: name.to_owned(),
2411            role: "test".into(),
2412            runner: "node:test".into(),
2413        };
2414        let mut tests = (0..17)
2415            .map(|n| {
2416                let name = if n == 16 {
2417                    "calls compute on start"
2418                } else {
2419                    "serves a request"
2420                };
2421                test(&format!("tests/e2e{n:02}.test.js"), name)
2422            })
2423            .collect::<Vec<_>>();
2424        tests.extend((0..3).map(|n| test("tests/zz_unit.test.js", &format!("total case {n}"))));
2425        let everyone = (0..20).collect::<Vec<u32>>();
2426        let end_to_end = (0..17).collect::<Vec<u32>>();
2427        let points = (0..10)
2428            .map(|n| InputPoint {
2429                file: "src/sum.js".into(),
2430                line: n + 1,
2431                source: format!("const v{n} = compute({n});"),
2432                tests: if n == 0 {
2433                    everyone.clone()
2434                } else {
2435                    end_to_end.clone()
2436                },
2437            })
2438            .collect();
2439        let input = AssessmentInput {
2440            tests,
2441            points,
2442            decisions: Vec::new(),
2443        };
2444        let sources = BTreeMap::from([("src/sum.js".to_owned(), source)]);
2445        let population = population_from(
2446            Path::new("/nonexistent"),
2447            &input,
2448            &sources,
2449            Language::JavaScript,
2450        )
2451        .unwrap();
2452        let target = population
2453            .statements
2454            .iter()
2455            .position(|s| s.line == 1)
2456            .unwrap();
2457        let unit = |order: &[usize]| {
2458            order
2459                .iter()
2460                .filter(|&&t| population.tests[t].file == "tests/zz_unit.test.js")
2461                .count()
2462        };
2463        let spread = population.order(target, MORE_TESTS);
2464        assert_eq!(
2465            unit(&spread),
2466            0,
2467            "file order alone leaves the unit tests out"
2468        );
2469        let last = population.last_order(target, &spread, LAST_TESTS);
2470        assert_eq!(last.len(), LAST_TESTS);
2471        assert!(last.iter().all(|t| !spread.contains(t)), "{last:?}");
2472        // The test naming what the line calls comes first, though it ran as
2473        // much as any; then the tests that ran the least.
2474        assert_eq!(population.tests[last[0]].name, "calls compute on start");
2475        assert_eq!(unit(&last[1..]), 2, "{last:?}");
2476        assert_eq!(
2477            name_words("const hostCount = getHTTPResponse2(send_data)"),
2478            [
2479                "const",
2480                "count",
2481                "data",
2482                "host",
2483                "http",
2484                "response2",
2485                "send"
2486            ]
2487            .map(String::from)
2488            .into()
2489        );
2490    }
2491
2492    #[test]
2493    fn a_statement_is_shown_with_the_function_it_is_in() {
2494        // The outermost one, with the statement that declares it and what
2495        // decorates it; a callback written inside it is part of it.
2496        let typescript = "import { log } from './log'\n\nexport const seal = (key: string) => {\n  const parts = key.split('-').map((part) => {\n    return part.trim()\n  })\n  return parts\n}\n\nclass Vault {\n  size = 0\n  @logged()\n  open(door: string) {\n    return () => door\n  }\n}\n\nserver.on('close', function () {\n  log('closed')\n})\n\nif (flag) {\n  function inner() {\n    return 1\n  }\n}\nconst limit = 4\n";
2497        assert_eq!(
2498            outermost_functions(Language::JavaScript, "src/vault.ts", typescript),
2499            [(3, 8), (12, 15), (18, 20), (23, 25)]
2500        );
2501        let python = "import os\n\nLIMIT = 4\n\n@cached\ndef seal(key):\n    def trim(part):\n        return part.strip()\n    return [trim(part) for part in key.split('-')]\n\nclass Vault:\n    size = 0\n\n    async def open(self, door):\n        return lambda: door\n\nif os.name == 'nt':\n    def separator():\n        return ';'\n";
2502        assert_eq!(
2503            outermost_functions(Language::Python, "vault.py", python),
2504            [(5, 9), (14, 15), (18, 19)]
2505        );
2506        // The other languages are asked as they were.
2507        assert!(outermost_functions(Language::Go, "vault.go", "package vault\n").is_empty());
2508        assert!(Language::Python.asks_within_the_function());
2509        assert!(!Language::Ruby.asks_within_the_function());
2510    }
2511
2512    #[test]
2513    fn changes_follow_the_audit() {
2514        let source = "import x from 'y'\nexport const a = 1\nlet b: number\nfunction f() {\n  if (a) return 2\n  return;\n}\nconst el = <div>{a && b}</div>\n";
2515        let starts = statement_starts("x.tsx", source);
2516        let js = Language::JavaScript;
2517        assert_eq!(change_at(&starts, 1, "import x from 'y'", js), None);
2518        assert_eq!(
2519            change_at(&starts, 2, "const a = 1", js),
2520            Some(Change::ValueUndefined)
2521        );
2522        assert_eq!(change_at(&starts, 3, "let b: number", js), None);
2523        assert_eq!(
2524            change_at(&starts, 5, "if (a) return 2", js),
2525            Some(Change::Invert)
2526        );
2527        assert_eq!(change_at(&starts, 6, "return;", js), Some(Change::Skip));
2528        assert_eq!(
2529            change_at(&starts, 8, "a && b", js),
2530            Some(Change::ExpressionUndefined)
2531        );
2532    }
2533
2534    #[test]
2535    fn a_change_that_cannot_change_anything_is_not_asked() {
2536        // supergateway's audit: `return undefined` "returning undefined" and
2537        // `let answered = false` "becoming undefined" (read only as `!answered`)
2538        // could never be caught, and skipping `super(...)` does not compile.
2539        let source = "class H extends Map {\n  constructor() {\n    super([])\n  }\n}\nfunction f(a) {\n  if (a) return undefined\n  if (!a) return void 0\n  let answered = false\n  const done = true, more = 1\n  return false\n}\n";
2540        let starts = statement_starts("x.ts", source);
2541        let js = Language::JavaScript;
2542        assert_eq!(change_at(&starts, 3, "super([])", js), None);
2543        assert_eq!(
2544            change_at(&starts, 7, "return undefined", js),
2545            Some(Change::Skip)
2546        );
2547        assert_eq!(
2548            change_at(&starts, 8, "return void 0", js),
2549            Some(Change::Skip)
2550        );
2551        // destr: `return undefined as T` was asked as "returns undefined", which no
2552        // test can catch, and read as the one statement not asserted.
2553        let typed = "function f<T>(a: number): T {\n  if (a === 1) return undefined as T\n  if (a === 2) return (undefined as unknown) as T\n  if (a === 3) return <T>undefined\n  return undefined!\n}\n";
2554        let starts = statement_starts("x.ts", typed);
2555        for (line, text) in [
2556            (2, "return undefined as T"),
2557            (3, "return (undefined as unknown) as T"),
2558            (4, "return <T>undefined"),
2559            (5, "return undefined!"),
2560        ] {
2561            assert_eq!(
2562                change_at(&starts, line, text, js),
2563                Some(Change::Skip),
2564                "{text}"
2565            );
2566        }
2567        let starts = statement_starts("x.ts", source);
2568        assert_eq!(
2569            change_at(&starts, 9, "let answered = false", js),
2570            Some(Change::Flip)
2571        );
2572        // Two declarators: the value change still applies to both.
2573        assert_eq!(
2574            change_at(&starts, 10, "const done = true, more = 1", js),
2575            Some(Change::ValueUndefined)
2576        );
2577        assert_eq!(
2578            change_at(&starts, 11, "return false", js),
2579            Some(Change::Flip)
2580        );
2581        let python = python_statement_starts(
2582            "def f(a):\n    if a:\n        return None\n    ready = False\n    return True\n",
2583        );
2584        let py = Language::Python;
2585        assert_eq!(change_at(&python, 3, "return None", py), Some(Change::Skip));
2586        assert_eq!(
2587            change_at(&python, 4, "ready = False", py),
2588            Some(Change::Flip)
2589        );
2590        assert_eq!(change_at(&python, 5, "return True", py), Some(Change::Flip));
2591        assert_eq!(flipped("let answered = false"), "let answered = true");
2592        assert_eq!(flipped("return isTrue && false;"), "return isTrue && true;");
2593        assert_eq!(flipped("ready = False"), "ready = True");
2594        assert_eq!(
2595            flipped("let ready = false // true once open"),
2596            "let ready = true // true once open"
2597        );
2598        assert_eq!(
2599            flipped("ready = False  # True later"),
2600            "ready = True  # True later"
2601        );
2602        assert_eq!(flipped("this.#open = false"), "this.#open = true");
2603        assert_eq!(
2604            flipped("const s = 'a // b', on = false"),
2605            "const s = 'a // b', on = true"
2606        );
2607    }
2608
2609    #[test]
2610    fn a_typescript_throw_keeps_its_types() {
2611        // supergateway's modernHttp.ts:294: deleting the guard's throw made
2612        // `result.result` fail to type-check on the next line (TS2339).
2613        let source = "async function list(reply) {\n  const result = await reply\n  if ('error' in result)\n    throw new Error(`tools/list failed: ${result.error.message}`)\n  return result.result\n}\n";
2614        let typed = statement_starts("x.ts", source);
2615        let js = Language::JavaScript;
2616        assert_eq!(
2617            change_at(
2618                &typed,
2619                4,
2620                "throw new Error(`tools/list failed: ${result.error.message}`)",
2621                js
2622            ),
2623            Some(Change::BareError)
2624        );
2625        let plain = statement_starts("x.js", source);
2626        assert_eq!(
2627            change_at(
2628                &plain,
2629                4,
2630                "throw new Error(`tools/list failed: ${result.error.message}`)",
2631                js
2632            ),
2633            Some(Change::Skip)
2634        );
2635    }
2636
2637    #[test]
2638    fn a_deleted_statement_says_what_goes_and_what_then_runs() {
2639        let source = "def f(items, n):\n    if n < 0:\n        raise ValueError(\"negative\")\n    if not items:\n        return None\n    for item in items:\n        use(item)\n    log(n)\n    return n\n";
2640        let point = |line: usize, text: &str| InputPoint {
2641            file: "pkg/f.py".into(),
2642            line,
2643            source: text.into(),
2644            tests: vec![0],
2645        };
2646        let input = AssessmentInput {
2647            tests: vec![InputTest {
2648                id: "tests/test_f.py::test_f".into(),
2649                file: Some("tests/test_f.py".into()),
2650                name: "tests/test_f.py::test_f".into(),
2651                role: "test".into(),
2652                runner: "pytest".into(),
2653            }],
2654            points: vec![
2655                point(3, "raise ValueError(\"negative\")"),
2656                point(5, "return None"),
2657                point(6, "for item in items:\n        use(item)"),
2658                point(8, "log(n)"),
2659            ],
2660            decisions: Vec::new(),
2661        };
2662        let sources = BTreeMap::from([("pkg/f.py".to_owned(), source.to_owned())]);
2663        let python = population_from(
2664            Path::new("/nonexistent"),
2665            &input,
2666            &sources,
2667            Language::Python,
2668        )
2669        .unwrap();
2670        let question = |population: &Population, line: usize| {
2671            let at = population
2672                .statements
2673                .iter()
2674                .position(|s| s.line == line)
2675                .unwrap();
2676            population.questions(at).join(" | ")
2677        };
2678        assert_eq!(
2679            question(&python, 3),
2680            "pkg/f.py:3: `raise ValueError(\"negative\")` is deleted: no error is raised here, and execution carries on with whatever follows it."
2681        );
2682        assert_eq!(
2683            question(&python, 5),
2684            "pkg/f.py:5: `return None` is deleted: the function does not return here, and execution carries on with whatever follows it."
2685        );
2686        assert_eq!(
2687            question(&python, 6),
2688            "pkg/f.py:6: the whole statement starting `for item in items:` is deleted, all 2 lines of it (lines 6 to 7): none of it runs."
2689        );
2690        assert_eq!(
2691            question(&python, 8),
2692            "pkg/f.py:8: `log(n)` is deleted (it never runs)."
2693        );
2694        // A JavaScript return keeps the plain wording: it was judged worse
2695        // the other way.
2696        let js = AssessmentInput {
2697            tests: vec![InputTest {
2698                id: "t".into(),
2699                file: Some("test/g.test.js".into()),
2700                name: "g".into(),
2701                role: "test".into(),
2702                runner: "node:test".into(),
2703            }],
2704            points: vec![InputPoint {
2705                file: "src/g.js".into(),
2706                line: 3,
2707                source: "return;".into(),
2708                tests: vec![0],
2709            }],
2710            decisions: Vec::new(),
2711        };
2712        let sources = BTreeMap::from([(
2713            "src/g.js".to_owned(),
2714            "function g(a) {\n  if (a) {\n    return;\n  }\n  use(a);\n}\n".to_owned(),
2715        )]);
2716        let javascript = population_from(
2717            Path::new("/nonexistent"),
2718            &js,
2719            &sources,
2720            Language::JavaScript,
2721        )
2722        .unwrap();
2723        assert_eq!(
2724            question(&javascript, 3),
2725            "src/g.js:3: `return;` is deleted (it never runs)."
2726        );
2727    }
2728
2729    #[test]
2730    fn a_statement_s_text_is_what_a_test_would_quote() {
2731        assert_eq!(
2732            quoted_fragments("logger.info(`  - Headers: ${describeHeaders(headers)}`)"),
2733            vec!["- Headers:"]
2734        );
2735        assert_eq!(
2736            quoted_fragments("logger.error(`Child exited: code=${code}, signal=${signal}`)"),
2737            vec![", signal=", "Child exited: code="]
2738        );
2739        assert_eq!(
2740            quoted_fragments("log(f\"Serving {path} on {port}\")"),
2741            vec!["Serving"]
2742        );
2743        // Short or letterless pieces say nothing about a test.
2744        assert!(quoted_fragments("x = 'abc' + \"1234567\"").is_empty());
2745        assert!(quoted_fragments("return a + b").is_empty());
2746    }
2747
2748    #[test]
2749    fn python_changes_follow_the_audit() {
2750        let source = "import os\n\n\ndef f(x):\n    \"\"\"Docs.\"\"\"\n    y = x + 1\n    if y > 2:\n        return y\n    z: int\n    count += 1\n    return\ndef g(v):\n    if v:\n        pass\n    elif v is None:\n        pass\n";
2751        let starts = python_statement_starts(source);
2752        let py = Language::Python;
2753        assert_eq!(change_at(&starts, 1, "import os", py), None);
2754        assert_eq!(change_at(&starts, 4, "def f(x):", py), None);
2755        assert_eq!(change_at(&starts, 5, "\"\"\"Docs.\"\"\"", py), None);
2756        assert_eq!(
2757            change_at(&starts, 6, "y = x + 1", py),
2758            Some(Change::ValueUndefined)
2759        );
2760        assert_eq!(change_at(&starts, 7, "if y > 2:", py), Some(Change::Invert));
2761        assert_eq!(
2762            change_at(&starts, 8, "return y", py),
2763            Some(Change::ReturnUndefined)
2764        );
2765        assert_eq!(change_at(&starts, 9, "z: int", py), None);
2766        assert_eq!(change_at(&starts, 10, "count += 1", py), Some(Change::Skip));
2767        assert_eq!(change_at(&starts, 11, "return", py), Some(Change::Skip));
2768        assert_eq!(
2769            change_at(&starts, 15, "v is None", py),
2770            Some(Change::Invert)
2771        );
2772        assert_eq!(
2773            python_title("tests/test_a.py::TestA::test_b[1.5-x]"),
2774            "test_b"
2775        );
2776        assert_eq!(python_title("tests.test_a.TestA.test_b"), "test_b");
2777        assert_eq!(python_condition("if y > 2:"), "y > 2");
2778        assert_eq!(python_condition("elif v is None:"), "v is None");
2779    }
2780
2781    #[test]
2782    fn change_texts() {
2783        assert_eq!(
2784            split_initializer("const x = a === b").as_deref(),
2785            Some("const x =")
2786        );
2787        assert_eq!(
2788            split_initializer("const f = () => 1").as_deref(),
2789            Some("const f =")
2790        );
2791        assert_eq!(condition("} else if (x > 1) {"), "x > 1");
2792        assert_eq!(
2793            condition("if (!body) return null"),
2794            "if (!body) return null"
2795        );
2796        assert!(is_test_call("  it(\"works\", () => {"));
2797        assert!(is_test_call("test.each([1])('x', () => {"));
2798        assert!(!is_test_call("items.push(1)"));
2799    }
2800
2801    #[test]
2802    fn keys_ignore_line_numbers() {
2803        assert_eq!(
2804            strip_numbers("▶   12   return x\n        ... 3 lines not shown\n    15 }"),
2805            "▶  return x\n...\n }"
2806        );
2807        assert_eq!(
2808            strip_location("src/a.ts:41: `x` is deleted."),
2809            "src/a.ts:: `x` is deleted."
2810        );
2811    }
2812}