use super::super::{AnalysisOptions, diff::ChangedFile};
use super::{LanguageAdapter, LanguageDiffResult, LanguageId, LanguageRepoResult, route};
use crate::config::OraclePolicy;
use crate::domain::{
Confidence, DeltaKind, ExposureClass, Finding, LanguageId as DomainLanguageId, LanguageStatus,
OracleKind, OracleStrength, Probe, ProbeFamily, ProbeId, RelatedTest, RevealEvidence,
RiprEvidence, SourceLocation, StageEvidence, StageState, StaticLimitKind,
};
use oxc_allocator::Allocator;
use oxc_ast::ast::{Expression, Statement};
use oxc_parser::Parser;
use oxc_span::SourceType;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct TypeScriptAdapter;
fn source_type_for(path: &Path) -> SourceType {
match path.extension().and_then(|ext| ext.to_str()) {
Some("tsx") => SourceType::tsx(),
Some("ts") => SourceType::ts(),
Some("jsx") => SourceType::jsx(),
Some("js") => SourceType::mjs(),
_ => SourceType::mjs(),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct TypeScriptOwner {
name: String,
file: PathBuf,
start_line: usize,
end_line: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct TypeScriptTest {
name: String,
file: PathBuf,
line: usize,
body_text: String,
assertions: Vec<TypeScriptAssertion>,
mocks_in_file: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct TypeScriptAssertion {
matcher: String,
argument_count: usize,
line: usize,
oracle_kind: OracleKind,
oracle_strength: OracleStrength,
}
fn oracle_for_matcher(matcher: &str) -> (OracleKind, OracleStrength) {
match matcher {
"toBe" | "toEqual" | "toStrictEqual" => (OracleKind::ExactValue, OracleStrength::Strong),
"toThrow" | "toThrowError" => (OracleKind::BroadError, OracleStrength::Weak),
"toMatchSnapshot" | "toMatchInlineSnapshot" => {
(OracleKind::Snapshot, OracleStrength::Medium)
}
"toHaveBeenCalled"
| "toHaveBeenCalledWith"
| "toHaveBeenCalledTimes"
| "toHaveBeenLastCalledWith"
| "toHaveBeenNthCalledWith" => (OracleKind::MockExpectation, OracleStrength::Medium),
"toBeTruthy" | "toBeFalsy" | "toBeDefined" | "toBeUndefined" | "toBeNull" | "toBeNaN" => {
(OracleKind::SmokeOnly, OracleStrength::Smoke)
}
"toContain"
| "toMatch"
| "toBeGreaterThan"
| "toBeGreaterThanOrEqual"
| "toBeLessThan"
| "toBeLessThanOrEqual"
| "toHaveLength"
| "toHaveProperty" => (OracleKind::RelationalCheck, OracleStrength::Weak),
_ => (OracleKind::Unknown, OracleStrength::Unknown),
}
}
fn is_test_file(path: &Path) -> bool {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
let stem_extensions: &[&str] = &[
".test.ts",
".test.tsx",
".test.js",
".test.jsx",
".spec.ts",
".spec.tsx",
".spec.js",
".spec.jsx",
];
stem_extensions
.iter()
.any(|suffix| file_name.ends_with(suffix))
}
fn line_for_offset(source: &str, offset: usize) -> usize {
let mut line: usize = 1;
for (idx, ch) in source.char_indices() {
if idx >= offset {
break;
}
if ch == '\n' {
line += 1;
}
}
line
}
fn normalized_path(path: &Path) -> String {
let mut normalized = path.to_string_lossy().replace('\\', "/");
while let Some(stripped) = normalized.strip_prefix("./") {
normalized = stripped.to_string();
}
normalized
}
fn extract_owners(file: &Path, source: &str) -> Vec<TypeScriptOwner> {
let allocator = Allocator::default();
let ret = Parser::new(&allocator, source, source_type_for(file)).parse();
if !ret.errors.is_empty() {
return Vec::new();
}
let mut owners = Vec::new();
for stmt in &ret.program.body {
if let Some(owner) = owner_from_statement(stmt, file, source) {
owners.push(owner);
}
}
owners
}
fn owner_from_statement(
stmt: &Statement<'_>,
file: &Path,
source: &str,
) -> Option<TypeScriptOwner> {
if let Statement::FunctionDeclaration(func) = stmt
&& let Some(id) = &func.id
{
return Some(TypeScriptOwner {
name: id.name.to_string(),
file: file.to_path_buf(),
start_line: line_for_offset(source, func.span.start as usize),
end_line: line_for_offset(source, func.span.end as usize),
});
}
if let Statement::ExportNamedDeclaration(export) = stmt
&& let Some(decl) = export.declaration.as_ref()
&& let oxc_ast::ast::Declaration::FunctionDeclaration(func) = decl
&& let Some(id) = &func.id
{
return Some(TypeScriptOwner {
name: id.name.to_string(),
file: file.to_path_buf(),
start_line: line_for_offset(source, func.span.start as usize),
end_line: line_for_offset(source, func.span.end as usize),
});
}
None
}
fn extract_tests(file: &Path, source: &str) -> Vec<TypeScriptTest> {
let allocator = Allocator::default();
let ret = Parser::new(&allocator, source, source_type_for(file)).parse();
if !ret.errors.is_empty() {
return Vec::new();
}
let mocks = extract_mocks_from_statements(&ret.program.body);
let mut tests = Vec::new();
for stmt in &ret.program.body {
if let Some(mut test) = test_from_statement(stmt, file, source) {
test.mocks_in_file = mocks.clone();
tests.push(test);
}
}
tests
}
fn extract_mocks_from_statements(
statements: &oxc_allocator::Vec<'_, Statement<'_>>,
) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for stmt in statements {
let Statement::ExpressionStatement(expr_stmt) = stmt else {
continue;
};
let Expression::CallExpression(call) = &expr_stmt.expression else {
continue;
};
let Expression::StaticMemberExpression(member) = &call.callee else {
continue;
};
let Expression::Identifier(object_ident) = &member.object else {
continue;
};
let object_name = object_ident.name.as_str();
if object_name != "vi" && object_name != "jest" {
continue;
}
if member.property.name.as_str() != "mock" {
continue;
}
let Some(first_arg) = call.arguments.first() else {
continue;
};
let oxc_ast::ast::Argument::StringLiteral(literal) = first_arg else {
continue;
};
let path = literal.value.to_string();
if !out.iter().any(|existing| existing == &path) {
out.push(path);
}
}
out
}
fn test_from_statement(stmt: &Statement<'_>, file: &Path, source: &str) -> Option<TypeScriptTest> {
let Statement::ExpressionStatement(expr_stmt) = stmt else {
return None;
};
let Expression::CallExpression(call) = &expr_stmt.expression else {
return None;
};
let Expression::Identifier(ident) = &call.callee else {
return None;
};
let callee_name = ident.name.as_str();
if callee_name != "test" && callee_name != "it" {
return None;
}
let mut args = call.arguments.iter();
let name_arg = args.next()?;
let name = match name_arg {
oxc_ast::ast::Argument::StringLiteral(literal) => literal.value.to_string(),
_ => return None,
};
let assertions = match args.next() {
Some(oxc_ast::ast::Argument::ArrowFunctionExpression(arrow)) => {
collect_expect_assertions_in_statements(&arrow.body.statements, source)
}
Some(oxc_ast::ast::Argument::FunctionExpression(func)) => match &func.body {
Some(body) => collect_expect_assertions_in_statements(&body.statements, source),
None => Vec::new(),
},
_ => Vec::new(),
};
Some(TypeScriptTest {
name,
file: file.to_path_buf(),
line: line_for_offset(source, call.span.start as usize),
body_text: source[call.span.start as usize..call.span.end as usize].to_string(),
assertions,
mocks_in_file: Vec::new(),
})
}
fn collect_expect_assertions_in_statements(
statements: &oxc_allocator::Vec<'_, Statement<'_>>,
source: &str,
) -> Vec<TypeScriptAssertion> {
let mut out = Vec::new();
for stmt in statements {
collect_expect_assertions_in_statement(stmt, source, &mut out);
}
out
}
fn collect_expect_assertions_in_statement(
stmt: &Statement<'_>,
source: &str,
out: &mut Vec<TypeScriptAssertion>,
) {
match stmt {
Statement::BlockStatement(block) => {
collect_expect_assertions_from_statement_vec(&block.body, source, out);
}
Statement::ExpressionStatement(expr_stmt) => {
if let Some(assertion) = expect_assertion_from_expression(&expr_stmt.expression, source)
{
out.push(assertion);
}
}
Statement::ReturnStatement(return_stmt) => {
if let Some(argument) = &return_stmt.argument
&& let Some(assertion) = expect_assertion_from_expression(argument, source)
{
out.push(assertion);
}
}
Statement::IfStatement(if_stmt) => {
collect_expect_assertions_in_statement(&if_stmt.consequent, source, out);
if let Some(alternate) = &if_stmt.alternate {
collect_expect_assertions_in_statement(alternate, source, out);
}
}
Statement::DoWhileStatement(do_while) => {
collect_expect_assertions_in_statement(&do_while.body, source, out);
}
Statement::WhileStatement(while_stmt) => {
collect_expect_assertions_in_statement(&while_stmt.body, source, out);
}
Statement::ForStatement(for_stmt) => {
collect_expect_assertions_in_statement(&for_stmt.body, source, out);
}
Statement::ForInStatement(for_in) => {
collect_expect_assertions_in_statement(&for_in.body, source, out);
}
Statement::ForOfStatement(for_of) => {
collect_expect_assertions_in_statement(&for_of.body, source, out);
}
Statement::LabeledStatement(labeled) => {
collect_expect_assertions_in_statement(&labeled.body, source, out);
}
Statement::SwitchStatement(switch_stmt) => {
for case in &switch_stmt.cases {
collect_expect_assertions_from_statement_vec(&case.consequent, source, out);
}
}
Statement::TryStatement(try_stmt) => {
collect_expect_assertions_from_statement_vec(&try_stmt.block.body, source, out);
if let Some(handler) = &try_stmt.handler {
collect_expect_assertions_from_statement_vec(&handler.body.body, source, out);
}
if let Some(finalizer) = &try_stmt.finalizer {
collect_expect_assertions_from_statement_vec(&finalizer.body, source, out);
}
}
Statement::WithStatement(with_stmt) => {
collect_expect_assertions_in_statement(&with_stmt.body, source, out);
}
_ => {}
}
}
fn collect_expect_assertions_from_statement_vec(
statements: &oxc_allocator::Vec<'_, Statement<'_>>,
source: &str,
out: &mut Vec<TypeScriptAssertion>,
) {
for stmt in statements {
collect_expect_assertions_in_statement(stmt, source, out);
}
}
fn expect_assertion_from_expression(
expr: &Expression<'_>,
source: &str,
) -> Option<TypeScriptAssertion> {
let expr = match expr {
Expression::AwaitExpression(await_expr) => &await_expr.argument,
_ => expr,
};
let Expression::CallExpression(outer_call) = expr else {
return None;
};
let Expression::StaticMemberExpression(outer_member) = &outer_call.callee else {
return None;
};
let matcher = outer_member.property.name.as_str();
let inner = &outer_member.object;
let inner_is_expect_call = match inner {
Expression::CallExpression(inner_call) => {
matches!(
&inner_call.callee,
Expression::Identifier(ident) if ident.name.as_str() == "expect"
)
}
Expression::StaticMemberExpression(inner_member) => {
let modifier = inner_member.property.name.as_str();
if modifier != "resolves" && modifier != "rejects" {
return None;
}
matches!(
&inner_member.object,
Expression::CallExpression(inner_call)
if matches!(&inner_call.callee, Expression::Identifier(ident) if ident.name.as_str() == "expect")
)
}
_ => false,
};
if !inner_is_expect_call {
return None;
}
let (oracle_kind, oracle_strength) = oracle_for_matcher(matcher);
Some(TypeScriptAssertion {
matcher: matcher.to_string(),
argument_count: outer_call.arguments.len(),
line: line_for_offset(source, outer_call.span.start as usize),
oracle_kind,
oracle_strength,
})
}
fn find_related_tests(owner: &TypeScriptOwner, all_tests: &[TypeScriptTest]) -> Vec<RelatedTest> {
all_tests
.iter()
.filter(|test| test_references_owner(test, owner))
.map(|test| {
let strongest = strongest_assertion(&test.assertions);
let (oracle_kind, oracle_strength, oracle_text) = match strongest {
Some(assertion) => (
assertion.oracle_kind.clone(),
assertion.oracle_strength.clone(),
Some(assertion_oracle_text(assertion)),
),
None => (OracleKind::Unknown, OracleStrength::Unknown, None),
};
RelatedTest {
name: test.name.clone(),
file: test.file.clone(),
line: test.line,
oracle: oracle_text,
oracle_kind,
oracle_strength,
}
})
.collect()
}
fn test_references_owner(test: &TypeScriptTest, owner: &TypeScriptOwner) -> bool {
contains_call_name(&test.body_text, &owner.name)
}
fn contains_call_name(body_text: &str, call_name: &str) -> bool {
let needle = format!("{call_name}(");
body_text.match_indices(&needle).any(|(idx, _)| {
has_call_boundary(body_text, idx)
&& !line_prefix_looks_like_comment_or_string(body_text, idx)
&& !inside_block_comment(body_text, idx)
})
}
fn has_call_boundary(body_text: &str, idx: usize) -> bool {
body_text[..idx]
.chars()
.next_back()
.is_none_or(|ch| !is_javascript_identifier_char(ch) && ch != '.')
}
fn line_prefix_looks_like_comment_or_string(body_text: &str, idx: usize) -> bool {
let line_start = body_text[..idx].rfind('\n').map_or(0, |offset| offset + 1);
let prefix = &body_text[line_start..idx];
prefix.trim_start().starts_with("//") || has_unclosed_quote_or_template(prefix)
}
fn inside_block_comment(body_text: &str, idx: usize) -> bool {
let prefix = &body_text[..idx];
let comment_start = prefix.rfind("/*");
let comment_end = prefix.rfind("*/");
comment_start.is_some_and(|start| comment_end.is_none_or(|end| start > end))
}
fn has_unclosed_quote_or_template(prefix: &str) -> bool {
let mut escaped = false;
let mut in_single = false;
let mut in_double = false;
let mut in_template = false;
for ch in prefix.chars() {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == '\'' && !in_double && !in_template {
in_single = !in_single;
} else if ch == '"' && !in_single && !in_template {
in_double = !in_double;
} else if ch == '`' && !in_single && !in_double {
in_template = !in_template;
}
}
in_single || in_double || in_template
}
fn is_javascript_identifier_char(ch: char) -> bool {
ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()
}
fn assertion_oracle_text(assertion: &TypeScriptAssertion) -> String {
if matches!(assertion.matcher.as_str(), "toThrow" | "toThrowError")
&& assertion.argument_count == 0
{
format!("expect(...).{}()", assertion.matcher)
} else {
format!("expect(...).{}(...)", assertion.matcher)
}
}
fn strongest_assertion(assertions: &[TypeScriptAssertion]) -> Option<&TypeScriptAssertion> {
assertions
.iter()
.max_by_key(|assertion| assertion.oracle_strength.rank())
}
fn collect_related_mock_paths(
owner: &TypeScriptOwner,
all_tests: &[TypeScriptTest],
) -> Vec<String> {
let mut paths: Vec<String> = Vec::new();
for test in all_tests
.iter()
.filter(|test| test_references_owner(test, owner))
{
for path in &test.mocks_in_file {
if !paths.iter().any(|existing| existing == path) {
paths.push(path.clone());
}
}
}
paths
}
fn classify_probe_shape(line_text: &str) -> (ProbeFamily, DeltaKind) {
let trimmed = line_text.trim_start();
let leading = trimmed.strip_prefix("} ").unwrap_or(trimmed).trim_start();
if leading.starts_with("throw ")
|| leading.starts_with("throw(")
|| leading.starts_with("return Promise.reject(")
|| leading.starts_with("return Promise.reject ")
|| leading.starts_with("return await Promise.reject(")
|| leading.starts_with("return await Promise.reject ")
|| leading.starts_with("await Promise.reject(")
|| leading.starts_with("await Promise.reject ")
|| leading.starts_with("} catch ")
|| leading.starts_with("catch ")
{
return (ProbeFamily::ErrorPath, DeltaKind::Control);
}
if leading.starts_with("return ") || leading == "return;" || leading.starts_with("return;") {
return (ProbeFamily::ReturnValue, DeltaKind::Value);
}
if leading.starts_with("if (")
|| leading.starts_with("if(")
|| leading.starts_with("else if (")
|| leading.starts_with("else if(")
|| leading.starts_with("while (")
|| leading.starts_with("while(")
|| leading.starts_with("for (")
|| leading.starts_with("for(")
|| leading.starts_with("switch (")
|| leading.starts_with("switch(")
|| leading.starts_with("case ")
|| leading.starts_with("default:")
{
return (ProbeFamily::Predicate, DeltaKind::Control);
}
if (leading.contains("? ") && leading.contains(" : "))
&& !leading.starts_with("const ")
&& !leading.starts_with("let ")
&& !leading.starts_with("var ")
{
return (ProbeFamily::Predicate, DeltaKind::Control);
}
if let Some(eq_idx) = leading.find(" = ")
&& !leading.starts_with("if ")
&& !leading.starts_with("else ")
&& !leading.starts_with("return")
&& !leading.starts_with("throw")
{
let lhs = &leading[..eq_idx];
let looks_like_assignment = lhs
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '[' || c == ']');
let looks_like_declaration =
lhs.starts_with("const ") || lhs.starts_with("let ") || lhs.starts_with("var ");
if looks_like_assignment && !looks_like_declaration {
return (ProbeFamily::FieldConstruction, DeltaKind::Value);
}
}
let call_candidate = leading
.strip_prefix("await ")
.unwrap_or(leading)
.strip_prefix("void ")
.unwrap_or_else(|| leading.strip_prefix("await ").unwrap_or(leading))
.trim_end();
let call_candidate = call_candidate
.strip_suffix(';')
.unwrap_or(call_candidate)
.trim_end();
if call_candidate.ends_with(')')
&& call_candidate.contains('(')
&& !call_candidate.starts_with("if")
&& !call_candidate.starts_with("while")
&& !call_candidate.starts_with("for")
&& !call_candidate.starts_with("switch")
&& !call_candidate.starts_with("return")
&& !call_candidate.starts_with("throw")
&& !call_candidate.starts_with("const ")
&& !call_candidate.starts_with("let ")
&& !call_candidate.starts_with("var ")
{
return (ProbeFamily::SideEffect, DeltaKind::Effect);
}
(ProbeFamily::Predicate, DeltaKind::Control)
}
fn classify_change(
file: &Path,
line: usize,
line_text: &str,
owners: &[TypeScriptOwner],
all_tests: &[TypeScriptTest],
) -> Option<Finding> {
let changed_file = normalized_path(file);
let owner = owners
.iter()
.filter(|owner| normalized_path(&owner.file) == changed_file)
.find(|owner| line >= owner.start_line && line <= owner.end_line)?;
let related = find_related_tests(owner, all_tests);
let mock_paths = collect_related_mock_paths(owner, all_tests);
let strongest_strength = related
.iter()
.map(|test| test.oracle_strength.rank())
.max()
.unwrap_or(0);
let strongest_kind = related
.iter()
.max_by_key(|test| test.oracle_strength.rank())
.map(|test| test.oracle_kind.clone())
.unwrap_or(OracleKind::Unknown);
let (class, reach_state, observe_state, discriminate_state, mut missing) = if related.is_empty()
{
(
ExposureClass::NoStaticPath,
StageState::No,
StageState::No,
StageState::No,
vec![format!(
"No test references `{}(` — add a test that calls the changed owner.",
owner.name
)],
)
} else if strongest_strength >= OracleStrength::Strong.rank() {
(
ExposureClass::Exposed,
StageState::Yes,
StageState::Yes,
StageState::Yes,
vec![format!(
"Related test reaches `{}` with a `{}` oracle. Static evidence suggests the changed behavior is observed under an exact-value or exact-error-variant discriminator.",
owner.name,
strongest_kind.as_str()
)],
)
} else {
(
ExposureClass::WeaklyExposed,
StageState::Yes,
StageState::Weak,
StageState::Weak,
vec![format!(
"Related test reaches `{}` but the strongest extracted oracle is `{}`; upgrade by adding an exact-value (`toBe` / `toEqual` / `toStrictEqual`) assertion. TypeScript `toThrow` forms remain broad error evidence until payload inspection lands.",
owner.name,
strongest_kind.as_str()
)],
)
};
if !mock_paths.is_empty() {
let preview: String = mock_paths
.iter()
.map(|path| format!("`{path}`"))
.collect::<Vec<_>>()
.join(", ");
missing.push(format!(
"Static limit `mocked_module`: related test file mocks {preview} via `vi.mock(...)` / `jest.mock(...)`. The TypeScript preview adapter does not resolve mocked module semantics, so the substitution under test is opaque to static evidence."
));
}
let id_path: String = file
.display()
.to_string()
.chars()
.map(|c| if c == '/' || c == '\\' { '_' } else { c })
.collect();
let (family, delta) = classify_probe_shape(line_text);
let probe = Probe {
id: ProbeId(format!("probe:{id_path}:{line}:typescript_preview")),
location: SourceLocation::new(file.to_string_lossy().as_ref(), line, 1),
owner: None,
family,
delta,
before: None,
after: Some(line_text.to_string()),
expression: line_text.to_string(),
expected_sinks: Vec::new(),
required_oracles: Vec::new(),
};
let related_count = related.len();
let reach_summary = format!(
"{} related test(s) found for owner `{}`",
related_count, owner.name
);
let reach = StageEvidence::new(reach_state.clone(), Confidence::Low, &reach_summary);
let infect = StageEvidence::new(
StageState::Unknown,
Confidence::Low,
"TypeScript preview adapter does not yet model infection.",
);
let propagate = StageEvidence::new(
StageState::Unknown,
Confidence::Low,
"TypeScript preview adapter does not yet model propagation.",
);
let observe_summary = format!(
"Strongest extracted oracle kind: `{}` (rank {})",
strongest_kind.as_str(),
strongest_strength
);
let observe = StageEvidence::new(observe_state, Confidence::Low, &observe_summary);
let discriminate_summary = if strongest_strength >= OracleStrength::Strong.rank() {
format!(
"Related test uses a `{}` oracle; static evidence suggests the changed behavior is discriminated.",
strongest_kind.as_str()
)
} else {
"TypeScript preview adapter found no strong discriminator; use `toBe` / `toEqual` / `toStrictEqual` to escalate. TypeScript `toThrow` forms remain broad error evidence until payload inspection lands.".to_string()
};
let discriminate =
StageEvidence::new(discriminate_state, Confidence::Low, &discriminate_summary);
let recommended = match &class {
ExposureClass::Exposed => {
"TypeScript preview: changed behavior is observed under a strong oracle; verify the assertion targets the changed boundary value.".to_string()
}
ExposureClass::NoStaticPath => {
"TypeScript preview: no test references the changed owner; add a test that calls the owner and asserts the changed behavior with `toBe` / `toEqual`.".to_string()
}
_ => {
"TypeScript preview: add a test that exercises the changed behavior with an exact-value assertion (`toBe` / `toEqual` / `toStrictEqual`).".to_string()
}
};
let confidence_value = if matches!(class, ExposureClass::Exposed) {
0.6
} else {
0.4
};
let mut evidence = vec![format!("owner: {}", owner.name)];
for path in &mock_paths {
evidence.push(format!("static_limit mocked_module: `{path}`"));
}
Some(Finding {
id: probe.id.0.clone(),
probe,
class,
ripr: RiprEvidence {
reach,
infect,
propagate,
reveal: RevealEvidence {
observe,
discriminate,
},
},
confidence: confidence_value,
evidence,
missing,
flow_sinks: Vec::new(),
activation: Default::default(),
stop_reasons: Vec::new(),
related_tests: related,
recommended_next_step: Some(recommended),
language: Some(DomainLanguageId::TypeScript),
language_status: Some(LanguageStatus::Preview),
owner_kind: None,
static_limit_kind: (!mock_paths.is_empty()).then_some(StaticLimitKind::MockedModule),
})
}
fn collect_workspace_typescript_files(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
visit_workspace(root, root, &mut out);
out.sort();
out
}
fn visit_workspace(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
if name == ".git"
|| name == "target"
|| name == "node_modules"
|| name == ".ripr"
|| name == ".direnv"
{
continue;
}
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(_) => continue,
};
if file_type.is_dir() {
visit_workspace(root, &path, out);
} else if file_type.is_file() {
let adapter = TypeScriptAdapter;
if adapter.accepts_path(&path) {
let relative = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
out.push(relative);
}
}
}
}
impl LanguageAdapter for TypeScriptAdapter {
fn accepts_path(&self, path: &Path) -> bool {
matches!(route(path), Some(LanguageId::TypeScript))
}
fn analyze_diff(
&self,
options: &AnalysisOptions,
_oracle_policy: &OraclePolicy,
changed_files: &[ChangedFile],
) -> Result<LanguageDiffResult, String> {
let workspace_files = collect_workspace_typescript_files(&options.root);
let mut all_owners: Vec<TypeScriptOwner> = Vec::new();
let mut all_tests: Vec<TypeScriptTest> = Vec::new();
for relative in &workspace_files {
let absolute = options.root.join(relative);
let Ok(source) = std::fs::read_to_string(&absolute) else {
continue;
};
if is_test_file(relative) {
all_tests.extend(extract_tests(relative, &source));
} else {
all_owners.extend(extract_owners(relative, &source));
}
}
let mut findings: Vec<Finding> = Vec::new();
let mut changed_count: usize = 0;
for changed in changed_files {
if !self.accepts_path(&changed.path) {
continue;
}
changed_count += 1;
if is_test_file(&changed.path) {
continue;
}
for added in &changed.added_lines {
if let Some(finding) = classify_change(
&changed.path,
added.line,
&added.text,
&all_owners,
&all_tests,
) {
findings.push(finding);
}
}
}
Ok(LanguageDiffResult {
findings,
changed_files: changed_count,
})
}
fn analyze_repo(
&self,
_options: &AnalysisOptions,
_oracle_policy: &OraclePolicy,
) -> Result<LanguageRepoResult, String> {
Ok(LanguageRepoResult {
findings: Vec::new(),
production_files: 0,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
fn changed(path: &str) -> ChangedFile {
ChangedFile {
path: PathBuf::from(path),
added_lines: Vec::new(),
removed_lines: Vec::new(),
}
}
#[test]
fn accepts_ts_jsx_paths() {
let adapter = TypeScriptAdapter;
assert!(adapter.accepts_path(Path::new("src/index.ts")));
assert!(adapter.accepts_path(Path::new("src/component.tsx")));
assert!(adapter.accepts_path(Path::new("src/index.js")));
assert!(adapter.accepts_path(Path::new("src/component.jsx")));
assert!(!adapter.accepts_path(Path::new("src/lib.rs")));
assert!(!adapter.accepts_path(Path::new("scripts/run.py")));
assert!(!adapter.accepts_path(Path::new("README.md")));
}
#[test]
fn extract_owners_returns_empty_when_source_does_not_parse() {
let owners = extract_owners(
Path::new("src/index.ts"),
"this is not :: valid +++ typescript",
);
assert!(owners.is_empty());
}
#[test]
fn is_test_file_matches_test_and_spec_suffixes() {
assert!(is_test_file(Path::new("tests/lib.test.ts")));
assert!(is_test_file(Path::new("src/Header.spec.tsx")));
assert!(is_test_file(Path::new("legacy.test.js")));
assert!(!is_test_file(Path::new("src/lib.ts")));
assert!(!is_test_file(Path::new("README.md")));
}
#[test]
fn line_for_offset_counts_newlines() {
let source = "line1\nline2\nline3\n";
assert_eq!(line_for_offset(source, 0), 1);
assert_eq!(line_for_offset(source, 5), 1);
assert_eq!(line_for_offset(source, 6), 2);
assert_eq!(line_for_offset(source, 12), 3);
}
#[test]
fn normalized_path_strips_dot_prefix_and_normalizes_separators() {
assert_eq!(normalized_path(Path::new(r".\src\b.ts")), "src/b.ts");
}
#[test]
fn extract_owners_recognizes_function_declaration() {
let owners = extract_owners(
Path::new("src/lib.ts"),
"function applyDiscount(amount: number): number {\n return amount;\n}\n",
);
assert_eq!(owners.len(), 1);
assert_eq!(owners[0].name, "applyDiscount");
assert_eq!(owners[0].start_line, 1);
}
#[test]
fn extract_owners_recognizes_exported_function() {
let owners = extract_owners(
Path::new("src/lib.ts"),
"export function publicHelper(): void {}\n",
);
assert_eq!(owners.len(), 1);
assert_eq!(owners[0].name, "publicHelper");
}
#[test]
fn extract_tests_recognizes_test_and_it_blocks() {
let tests = extract_tests(
Path::new("tests/lib.test.ts"),
r#"test("alpha", () => { expect(applyDiscount(50, 100)).toBe(50); });
it("beta", () => { expect(otherHelper()).toBe(true); });
"#,
);
assert_eq!(tests.len(), 2);
assert_eq!(tests[0].name, "alpha");
assert_eq!(tests[1].name, "beta");
assert!(tests[0].body_text.contains("applyDiscount(50, 100)"));
}
#[test]
fn find_related_tests_matches_by_call_name() {
let owner = TypeScriptOwner {
name: "applyDiscount".to_string(),
file: PathBuf::from("src/lib.ts"),
start_line: 1,
end_line: 5,
};
let tests = vec![
TypeScriptTest {
name: "alpha".to_string(),
file: PathBuf::from("tests/lib.test.ts"),
line: 1,
body_text: r#"test("alpha", () => { expect(applyDiscount(50, 100)).toBe(50); });"#
.to_string(),
assertions: Vec::new(),
mocks_in_file: Vec::new(),
},
TypeScriptTest {
name: "unrelated".to_string(),
file: PathBuf::from("tests/other.test.ts"),
line: 1,
body_text: r#"test("unrelated", () => { expect(otherHelper()).toBe(true); });"#
.to_string(),
assertions: Vec::new(),
mocks_in_file: Vec::new(),
},
];
let related = find_related_tests(&owner, &tests);
assert_eq!(related.len(), 1);
assert_eq!(related[0].name, "alpha");
}
#[test]
fn find_related_tests_ignores_object_method_calls_for_function_owners() {
let owner = TypeScriptOwner {
name: "applyDiscount".to_string(),
file: PathBuf::from("src/lib.ts"),
start_line: 1,
end_line: 5,
};
let tests = vec![TypeScriptTest {
name: "method call on another object".to_string(),
file: PathBuf::from("tests/cart.test.ts"),
line: 1,
body_text: "expect(order.applyDiscount(50)).toBe(40);".to_string(),
assertions: Vec::new(),
mocks_in_file: Vec::new(),
}];
let related = find_related_tests(&owner, &tests);
assert!(related.is_empty());
}
#[test]
fn find_related_tests_ignores_call_shaped_string_mentions() {
let owner = TypeScriptOwner {
name: "applyDiscount".to_string(),
file: PathBuf::from("src/lib.ts"),
start_line: 1,
end_line: 5,
};
let tests = vec![TypeScriptTest {
name: "string mention".to_string(),
file: PathBuf::from("tests/docs.test.ts"),
line: 1,
body_text: r#"expect("applyDiscount(").toContain("applyDiscount(");"#.to_string(),
assertions: Vec::new(),
mocks_in_file: Vec::new(),
}];
let related = find_related_tests(&owner, &tests);
assert!(related.is_empty());
}
#[test]
fn find_related_tests_ignores_call_shaped_comment_mentions() {
let owner = TypeScriptOwner {
name: "applyDiscount".to_string(),
file: PathBuf::from("src/lib.ts"),
start_line: 1,
end_line: 5,
};
let tests = vec![
TypeScriptTest {
name: "line comment mention".to_string(),
file: PathBuf::from("tests/docs.test.ts"),
line: 1,
body_text: "// applyDiscount(\nexpect(total).toBe(40);".to_string(),
assertions: Vec::new(),
mocks_in_file: Vec::new(),
},
TypeScriptTest {
name: "block comment mention".to_string(),
file: PathBuf::from("tests/docs.test.ts"),
line: 4,
body_text: "/* applyDiscount(\n */\nexpect(total).toBe(40);".to_string(),
assertions: Vec::new(),
mocks_in_file: Vec::new(),
},
];
let related = find_related_tests(&owner, &tests);
assert!(related.is_empty());
}
#[test]
fn classify_change_returns_weakly_exposed_when_related_test_exists() -> Result<(), String> {
let owner = TypeScriptOwner {
name: "applyDiscount".to_string(),
file: PathBuf::from("src/lib.ts"),
start_line: 1,
end_line: 5,
};
let test = TypeScriptTest {
name: "alpha".to_string(),
file: PathBuf::from("tests/lib.test.ts"),
line: 1,
body_text: "applyDiscount(50, 100)".to_string(),
assertions: Vec::new(),
mocks_in_file: Vec::new(),
};
let finding = classify_change(
Path::new("src/lib.ts"),
2,
" if (amount >= threshold) {",
&[owner],
&[test],
)
.ok_or_else(|| "expected a finding when an owner contains the changed line".to_string())?;
assert!(matches!(finding.class, ExposureClass::WeaklyExposed));
assert_eq!(finding.language, Some(DomainLanguageId::TypeScript));
assert_eq!(finding.language_status, Some(LanguageStatus::Preview));
assert_eq!(finding.related_tests.len(), 1);
Ok(())
}
#[test]
fn classify_change_matches_owner_file_before_line_range() -> Result<(), String> {
let owners = vec![
TypeScriptOwner {
name: "alphaScore".to_string(),
file: PathBuf::from("src/a.ts"),
start_line: 1,
end_line: 5,
},
TypeScriptOwner {
name: "betaScore".to_string(),
file: PathBuf::from("src/b.ts"),
start_line: 1,
end_line: 5,
},
];
let tests = vec![
TypeScriptTest {
name: "alpha keeps its threshold".to_string(),
file: PathBuf::from("tests/a.test.ts"),
line: 1,
body_text: "expect(alphaScore(12)).toBe(13);".to_string(),
assertions: Vec::new(),
mocks_in_file: Vec::new(),
},
TypeScriptTest {
name: "beta keeps its threshold".to_string(),
file: PathBuf::from("tests/b.test.ts"),
line: 1,
body_text: "expect(betaScore(12)).toBe(13);".to_string(),
assertions: Vec::new(),
mocks_in_file: Vec::new(),
},
];
let finding = classify_change(
Path::new("src/b.ts"),
2,
" if (value >= 10) {",
&owners,
&tests,
)
.ok_or_else(|| "expected the changed file's owner to be selected".to_string())?;
assert_eq!(finding.evidence, vec!["owner: betaScore"]);
assert_eq!(finding.related_tests.len(), 1);
assert_eq!(finding.related_tests[0].name, "beta keeps its threshold");
assert_eq!(
finding.related_tests[0].file,
PathBuf::from("tests/b.test.ts")
);
assert!(finding.missing.iter().all(|line| !line.contains("alpha")));
Ok(())
}
#[test]
fn extract_tests_collects_expect_to_be_as_strong_oracle() {
let tests = extract_tests(
Path::new("tests/lib.test.ts"),
r#"test("alpha", () => {
expect(applyDiscount(50, 100)).toBe(50);
expect(applyDiscount(10000, 100)).toEqual(9990);
});
"#,
);
assert_eq!(tests.len(), 1);
assert_eq!(tests[0].assertions.len(), 2);
assert_eq!(tests[0].assertions[0].matcher, "toBe");
assert_eq!(tests[0].assertions[0].oracle_kind, OracleKind::ExactValue);
assert_eq!(
tests[0].assertions[0].oracle_strength,
OracleStrength::Strong
);
assert_eq!(tests[0].assertions[1].matcher, "toEqual");
}
#[test]
fn extract_tests_recognizes_resolves_async_chain() {
let tests = extract_tests(
Path::new("tests/lib.test.ts"),
r#"test("async", async () => {
await expect(loader()).resolves.toBe(42);
});
"#,
);
assert_eq!(tests.len(), 1);
assert_eq!(tests[0].assertions.len(), 1);
assert_eq!(tests[0].assertions[0].matcher, "toBe");
assert_eq!(tests[0].assertions[0].oracle_kind, OracleKind::ExactValue);
}
#[test]
fn extract_tests_recognizes_return_await_resolves_async_chain() {
let tests = extract_tests(
Path::new("tests/lib.test.ts"),
r#"test("async return", async () => {
return await expect(loader()).resolves.toBe(42);
});
"#,
);
assert_eq!(tests.len(), 1);
assert_eq!(tests[0].assertions.len(), 1);
assert_eq!(tests[0].assertions[0].matcher, "toBe");
assert_eq!(tests[0].assertions[0].oracle_kind, OracleKind::ExactValue);
}
#[test]
fn extract_tests_collects_assertions_nested_in_control_flow() {
let tests = extract_tests(
Path::new("tests/lib.test.ts"),
r#"test("nested", () => {
if (enabled) {
expect(applyDiscount(50, 100)).toBe(50);
} else {
expect(applyDiscount(1, 100)).toEqual(1);
}
});
"#,
);
assert_eq!(tests.len(), 1);
assert_eq!(tests[0].assertions.len(), 2);
assert_eq!(tests[0].assertions[0].matcher, "toBe");
assert_eq!(tests[0].assertions[1].matcher, "toEqual");
}
#[test]
fn extract_tests_collects_assertions_nested_in_loop_switch_and_label_bodies() {
let tests = extract_tests(
Path::new("tests/lib.test.ts"),
r#"test("nested statements", () => {
while (enabled) {
expect(loopValue).toBe(1);
}
do {
expect(done).toBeTruthy();
} while (retry);
for (let index = 0; index < items.length; index++) {
expect(items[index]).toBeDefined();
}
for (const key in record) {
expect(record[key]).toEqual("value");
}
for (const item of items) {
expect(item).toBeDefined();
}
retry: {
expect(labelled).toBe(false);
}
switch (kind) {
case "a":
expect(kind).toBe("a");
break;
default:
expect(kind).toEqual("fallback");
}
});
"#,
);
assert_eq!(tests.len(), 1);
let matchers: Vec<&str> = tests[0]
.assertions
.iter()
.map(|assertion| assertion.matcher.as_str())
.collect();
assert_eq!(
matchers,
vec![
"toBe",
"toBeTruthy",
"toBeDefined",
"toEqual",
"toBeDefined",
"toBe",
"toBe",
"toEqual"
]
);
}
#[test]
fn extract_tests_collects_assertions_nested_in_try_catch_finally() {
let tests = extract_tests(
Path::new("tests/lib.test.ts"),
r#"test("try-catch", () => {
try {
expect(parseUser("Ada")).toEqual({ name: "Ada" });
} catch (err) {
expect(err).toBeDefined();
} finally {
expect(cleanup).toHaveBeenCalled();
}
});
"#,
);
assert_eq!(tests.len(), 1);
assert_eq!(tests[0].assertions.len(), 3);
assert_eq!(tests[0].assertions[0].matcher, "toEqual");
assert_eq!(tests[0].assertions[1].matcher, "toBeDefined");
assert_eq!(tests[0].assertions[2].matcher, "toHaveBeenCalled");
}
#[test]
fn extract_tests_unknown_matcher_maps_to_unknown_oracle() {
let tests = extract_tests(
Path::new("tests/lib.test.ts"),
r#"test("alpha", () => {
expect(applyDiscount(50, 100)).customDomainMatcher(50);
});
"#,
);
assert_eq!(tests.len(), 1);
assert_eq!(tests[0].assertions.len(), 1);
assert_eq!(tests[0].assertions[0].oracle_kind, OracleKind::Unknown);
assert_eq!(
tests[0].assertions[0].oracle_strength,
OracleStrength::Unknown
);
}
#[test]
fn extract_tests_maps_bare_tothrow_to_broad_error_oracle() {
let tests = extract_tests(
Path::new("tests/lib.test.ts"),
r#"test("throws", () => {
expect(() => parseUser("")).toThrow();
});
"#,
);
assert_eq!(tests.len(), 1);
assert_eq!(tests[0].assertions.len(), 1);
assert_eq!(tests[0].assertions[0].matcher, "toThrow");
assert_eq!(tests[0].assertions[0].argument_count, 0);
assert_eq!(tests[0].assertions[0].oracle_kind, OracleKind::BroadError);
assert_eq!(tests[0].assertions[0].oracle_strength, OracleStrength::Weak);
}
#[test]
fn extract_tests_keeps_payload_tothrow_broad_until_payload_is_inspected() {
let tests = extract_tests(
Path::new("tests/lib.test.ts"),
r#"test("throws", () => {
expect(() => parseUser("")).toThrow("empty user");
});
"#,
);
assert_eq!(tests.len(), 1);
assert_eq!(tests[0].assertions.len(), 1);
assert_eq!(tests[0].assertions[0].matcher, "toThrow");
assert_eq!(tests[0].assertions[0].argument_count, 1);
assert_eq!(tests[0].assertions[0].oracle_kind, OracleKind::BroadError);
assert_eq!(tests[0].assertions[0].oracle_strength, OracleStrength::Weak);
}
#[test]
fn oracle_for_matcher_covers_canonical_jest_vitest_set() {
assert_eq!(
oracle_for_matcher("toBe"),
(OracleKind::ExactValue, OracleStrength::Strong)
);
assert_eq!(
oracle_for_matcher("toEqual"),
(OracleKind::ExactValue, OracleStrength::Strong)
);
assert_eq!(
oracle_for_matcher("toThrow"),
(OracleKind::BroadError, OracleStrength::Weak)
);
assert_eq!(
oracle_for_matcher("toMatchSnapshot"),
(OracleKind::Snapshot, OracleStrength::Medium)
);
assert_eq!(
oracle_for_matcher("toHaveBeenCalledWith"),
(OracleKind::MockExpectation, OracleStrength::Medium)
);
assert_eq!(
oracle_for_matcher("toBeTruthy"),
(OracleKind::SmokeOnly, OracleStrength::Smoke)
);
assert_eq!(
oracle_for_matcher("toContain"),
(OracleKind::RelationalCheck, OracleStrength::Weak)
);
assert_eq!(
oracle_for_matcher("someUnknownMatcher"),
(OracleKind::Unknown, OracleStrength::Unknown)
);
}
#[test]
fn classify_change_returns_exposed_when_related_test_has_strong_oracle() -> Result<(), String> {
let owner = TypeScriptOwner {
name: "applyDiscount".to_string(),
file: PathBuf::from("src/lib.ts"),
start_line: 1,
end_line: 5,
};
let test = TypeScriptTest {
name: "alpha".to_string(),
file: PathBuf::from("tests/lib.test.ts"),
line: 1,
body_text: "applyDiscount(50, 100)".to_string(),
assertions: vec![TypeScriptAssertion {
matcher: "toBe".to_string(),
argument_count: 1,
line: 2,
oracle_kind: OracleKind::ExactValue,
oracle_strength: OracleStrength::Strong,
}],
mocks_in_file: Vec::new(),
};
let finding = classify_change(
Path::new("src/lib.ts"),
2,
" if (amount >= threshold) {",
&[owner],
&[test],
)
.ok_or_else(|| "expected a finding for the changed line".to_string())?;
assert!(matches!(finding.class, ExposureClass::Exposed));
assert_eq!(finding.related_tests.len(), 1);
assert_eq!(finding.related_tests[0].oracle_kind, OracleKind::ExactValue);
assert_eq!(
finding.related_tests[0].oracle_strength,
OracleStrength::Strong
);
Ok(())
}
#[test]
fn classify_change_returns_no_static_path_when_no_related_test() -> Result<(), String> {
let owner = TypeScriptOwner {
name: "applyDiscount".to_string(),
file: PathBuf::from("src/lib.ts"),
start_line: 1,
end_line: 5,
};
let finding = classify_change(
Path::new("src/lib.ts"),
2,
" if (amount >= threshold) {",
&[owner],
&[],
)
.ok_or_else(|| "expected a finding when an owner contains the changed line".to_string())?;
assert!(matches!(finding.class, ExposureClass::NoStaticPath));
assert!(finding.related_tests.is_empty());
Ok(())
}
#[test]
fn classify_change_returns_none_when_line_is_outside_any_owner() {
let owner = TypeScriptOwner {
name: "applyDiscount".to_string(),
file: PathBuf::from("src/lib.ts"),
start_line: 10,
end_line: 20,
};
let finding = classify_change(
Path::new("src/lib.ts"),
5,
"// top-level comment",
&[owner],
&[],
);
assert!(finding.is_none());
}
#[test]
fn analyze_diff_returns_zero_findings_and_counts_accepted_files() -> Result<(), String> {
let adapter = TypeScriptAdapter;
let options = AnalysisOptions {
root: PathBuf::from("/nonexistent_workspace"),
base: None,
diff_file: None,
mode: crate::analysis::AnalysisMode::Draft,
include_unchanged_tests: false,
};
let policy = OraclePolicy::default();
let changed_files = vec![
changed("src/index.ts"),
changed("src/lib.rs"),
changed("docs/README.md"),
changed("src/Header.tsx"),
];
let result = adapter.analyze_diff(&options, &policy, &changed_files)?;
assert!(result.findings.is_empty());
assert_eq!(result.changed_files, 2);
Ok(())
}
#[test]
fn analyze_repo_returns_empty_scaffold() -> Result<(), String> {
let adapter = TypeScriptAdapter;
let options = AnalysisOptions {
root: PathBuf::from("/nonexistent_workspace"),
base: None,
diff_file: None,
mode: crate::analysis::AnalysisMode::Deep,
include_unchanged_tests: false,
};
let policy = OraclePolicy::default();
let result = adapter.analyze_repo(&options, &policy)?;
assert!(result.findings.is_empty());
assert_eq!(result.production_files, 0);
Ok(())
}
#[test]
fn classify_probe_shape_recognises_if_predicate() {
let (family, delta) = classify_probe_shape(" if (amount >= threshold) {");
assert_eq!(family, ProbeFamily::Predicate);
assert_eq!(delta, DeltaKind::Control);
}
#[test]
fn classify_probe_shape_recognises_else_if_predicate() {
let (family, delta) = classify_probe_shape(" } else if (amount === 0) {");
assert_eq!(family, ProbeFamily::Predicate);
assert_eq!(delta, DeltaKind::Control);
}
#[test]
fn classify_probe_shape_recognises_return_value() {
let (family, delta) = classify_probe_shape(" return amount - 10;");
assert_eq!(family, ProbeFamily::ReturnValue);
assert_eq!(delta, DeltaKind::Value);
}
#[test]
fn classify_probe_shape_recognises_bare_return() {
let (family, delta) = classify_probe_shape(" return;");
assert_eq!(family, ProbeFamily::ReturnValue);
assert_eq!(delta, DeltaKind::Value);
}
#[test]
fn classify_probe_shape_recognises_throw_error_path() {
let (family, delta) = classify_probe_shape(" throw new Error('out of range');");
assert_eq!(family, ProbeFamily::ErrorPath);
assert_eq!(delta, DeltaKind::Control);
}
#[test]
fn classify_probe_shape_recognises_promise_reject_error_path() {
let (family, delta) = classify_probe_shape(" return Promise.reject(new Error('boom'));");
assert_eq!(family, ProbeFamily::ErrorPath);
assert_eq!(delta, DeltaKind::Control);
}
#[test]
fn classify_probe_shape_recognises_return_await_promise_reject_error_path() {
let (family, delta) =
classify_probe_shape(" return await Promise.reject(new Error('boom'));");
assert_eq!(family, ProbeFamily::ErrorPath);
assert_eq!(delta, DeltaKind::Control);
}
#[test]
fn classify_probe_shape_recognises_bare_await_promise_reject_error_path() {
let (family, delta) = classify_probe_shape(" await Promise.reject(new Error('boom'));");
assert_eq!(family, ProbeFamily::ErrorPath);
assert_eq!(delta, DeltaKind::Control);
}
#[test]
fn classify_probe_shape_recognises_field_construction() {
let (family, delta) = classify_probe_shape(" this.count = next;");
assert_eq!(family, ProbeFamily::FieldConstruction);
assert_eq!(delta, DeltaKind::Value);
}
#[test]
fn classify_probe_shape_recognises_side_effect_call() {
let (family, delta) = classify_probe_shape(" logger.record(event);");
assert_eq!(family, ProbeFamily::SideEffect);
assert_eq!(delta, DeltaKind::Effect);
}
#[test]
fn classify_probe_shape_recognises_await_side_effect_call() {
let (family, delta) = classify_probe_shape(" await logger.flush();");
assert_eq!(family, ProbeFamily::SideEffect);
assert_eq!(delta, DeltaKind::Effect);
}
#[test]
fn classify_probe_shape_recognises_ternary_as_predicate() {
let (family, delta) =
classify_probe_shape(" amount >= threshold ? amount - 10 : amount;");
assert_eq!(family, ProbeFamily::Predicate);
assert_eq!(delta, DeltaKind::Control);
}
#[test]
fn classify_probe_shape_falls_through_to_predicate_default_for_const_decl() {
let (family, delta) =
classify_probe_shape(" const total = applyDiscount(amount, threshold);");
assert_eq!(family, ProbeFamily::Predicate);
assert_eq!(delta, DeltaKind::Control);
}
#[test]
fn extract_tests_collects_vi_mock_paths_in_file() {
let source = r#"
import { vi } from "vitest";
vi.mock("./api");
vi.mock("./logger");
test("alpha", () => {
expect(applyDiscount(50, 100)).toBe(50);
});
"#;
let tests = extract_tests(Path::new("tests/lib.test.ts"), source);
assert_eq!(tests.len(), 1);
assert_eq!(
tests[0].mocks_in_file,
vec!["./api".to_string(), "./logger".to_string()]
);
}
#[test]
fn extract_tests_collects_jest_mock_paths_in_file() {
let source = r#"
jest.mock("./repository");
test("alpha", () => {
expect(applyDiscount(50, 100)).toBe(50);
});
"#;
let tests = extract_tests(Path::new("tests/lib.test.ts"), source);
assert_eq!(tests.len(), 1);
assert_eq!(tests[0].mocks_in_file, vec!["./repository".to_string()]);
}
#[test]
fn extract_tests_returns_empty_mock_list_when_no_mock_call() {
let source = r#"
test("alpha", () => {
expect(applyDiscount(50, 100)).toBe(50);
});
"#;
let tests = extract_tests(Path::new("tests/lib.test.ts"), source);
assert_eq!(tests.len(), 1);
assert!(tests[0].mocks_in_file.is_empty());
}
#[test]
fn collect_related_mock_paths_dedups_across_tests_in_same_file() {
let owner = TypeScriptOwner {
name: "applyDiscount".to_string(),
file: PathBuf::from("src/lib.ts"),
start_line: 1,
end_line: 5,
};
let tests = vec![
TypeScriptTest {
name: "alpha".to_string(),
file: PathBuf::from("tests/lib.test.ts"),
line: 1,
body_text: "applyDiscount(1, 2)".to_string(),
assertions: Vec::new(),
mocks_in_file: vec!["./api".to_string()],
},
TypeScriptTest {
name: "beta".to_string(),
file: PathBuf::from("tests/lib.test.ts"),
line: 2,
body_text: "applyDiscount(3, 4)".to_string(),
assertions: Vec::new(),
mocks_in_file: vec!["./api".to_string()],
},
];
let paths = collect_related_mock_paths(&owner, &tests);
assert_eq!(paths, vec!["./api".to_string()]);
}
#[test]
fn collect_related_mock_paths_ignores_unrelated_tests() {
let owner = TypeScriptOwner {
name: "applyDiscount".to_string(),
file: PathBuf::from("src/lib.ts"),
start_line: 1,
end_line: 5,
};
let tests = vec![TypeScriptTest {
name: "unrelated".to_string(),
file: PathBuf::from("tests/other.test.ts"),
line: 1,
body_text: "otherHelper()".to_string(),
assertions: Vec::new(),
mocks_in_file: vec!["./api".to_string()],
}];
let paths = collect_related_mock_paths(&owner, &tests);
assert!(paths.is_empty());
}
#[test]
fn collect_related_mock_paths_ignores_object_method_mentions() {
let owner = TypeScriptOwner {
name: "applyDiscount".to_string(),
file: PathBuf::from("src/lib.ts"),
start_line: 1,
end_line: 5,
};
let tests = vec![TypeScriptTest {
name: "unrelated method".to_string(),
file: PathBuf::from("tests/cart.test.ts"),
line: 1,
body_text: "expect(order.applyDiscount(50)).toBe(40);".to_string(),
assertions: Vec::new(),
mocks_in_file: vec!["./api".to_string()],
}];
let paths = collect_related_mock_paths(&owner, &tests);
assert!(paths.is_empty());
}
#[test]
fn classify_change_surfaces_mocked_module_static_limit_in_missing_and_evidence()
-> Result<(), String> {
let owner = TypeScriptOwner {
name: "applyDiscount".to_string(),
file: PathBuf::from("src/lib.ts"),
start_line: 1,
end_line: 5,
};
let tests = vec![TypeScriptTest {
name: "alpha".to_string(),
file: PathBuf::from("tests/lib.test.ts"),
line: 1,
body_text: "applyDiscount(50, 100)".to_string(),
assertions: Vec::new(),
mocks_in_file: vec!["./api".to_string()],
}];
let finding = classify_change(
Path::new("src/lib.ts"),
2,
" if (amount >= threshold) {",
&[owner],
&tests,
)
.ok_or_else(|| "expected a finding for the changed line".to_string())?;
assert!(
finding
.missing
.iter()
.any(|line| line.contains("Static limit `mocked_module`")
&& line.contains("./api"))
);
assert!(
finding
.evidence
.iter()
.any(|line| line.starts_with("static_limit mocked_module:"))
);
assert_eq!(
finding.static_limit_kind,
Some(StaticLimitKind::MockedModule)
);
Ok(())
}
}