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