Repository: carthage-software/mago (Rust)

Issue:
analyser: chained null coalease doesn't narrow type
### 🐞 Describe the Bug

currency key should be known to exist here

### 🔄 Steps to Reproduce

mago analyse

### ⚙️ Configuration (mago.toml)

```toml
n/a
```

### 📜 Command Output

```shell
warning[possibly-undefined-string-array-index]: Possibly undefined array key 'currency' accessed on `array{'amount': float|int|string, 'currency'?: non-empty-string}`.
   ┌─ mago.php:21:19
   │
21 │ $currency = $data['currency'];
   │                   ^^^^^^^^^^ Key 'currency' might not exist.
   │
   = The analysis indicates this specific key might not be set when this access occurs.
   = Help: Ensure the key 'currency' is always set before accessing it, or use `isset()` or the null coalesce operator (`??`) to handle potential missing keys.
```

### 📂 PHP Code Sample (If Applicable)

```php
<?php

declare(strict_types=1);
use Psl\Type;

/**
 * @var null|string $forceCurrency
 */
$forceCurrency = null;
$data = [];

$data = Type\shape([
    'amount' => Type\union(Type\string(), Type\float(), Type\int()),
    'currency' => Type\optional(Type\non_empty_string()),
])
    ->assert($data);


$forceCurrency ?? $data['currency'] ?? throw new \InvalidArgumentException('Currency is required.');

$currency = $data['currency'];
```

### 🖥️ Operating System

Linux

### 📦 How did you install Mago?

Homebrew (brew install mago)

### 📝 Additional Context

_No response_


Discussion:

Patch:
diff --git a/crates/analyzer/src/expression/binary/null_coalesce.rs b/crates/analyzer/src/expression/binary/null_coalesce.rs
index 470d0e48..d3ab8547 100644
--- a/crates/analyzer/src/expression/binary/null_coalesce.rs
+++ b/crates/analyzer/src/expression/binary/null_coalesce.rs
@@ -1,6 +1,8 @@
 use std::borrow::Cow;
 use std::rc::Rc;
 
+use ahash::HashSet;
+use mago_algebra::find_satisfying_assignments;
 use mago_codex::ttype::TType;
 use mago_codex::ttype::combine_union_types;
 use mago_codex::ttype::get_mixed;
@@ -16,7 +18,11 @@ use crate::artifacts::get_expression_range;
 use crate::code::IssueCode;
 use crate::context::Context;
 use crate::context::block::BlockContext;
+use crate::context::scope::if_scope::IfScope;
 use crate::error::AnalysisError;
+use crate::formula::get_formula;
+use crate::reconciler::reconcile_keyed_types;
+use crate::utils::conditional;
 
 /// Analyzes the null coalescing operator (`??`).
 ///
@@ -50,6 +56,7 @@ pub fn analyze_null_coalesce_operation<'ctx, 'arena>(
     };
 
     let result_type: TUnion;
+    let mut rhs_is_never = false;
 
     if lhs_type.is_null() {
         context.collector.report_with_code(
@@ -97,10 +104,46 @@ pub fn analyze_null_coalesce_operation<'ctx, 'arena>(
         let rhs_type =
             artifacts.get_expression_type(&binary.rhs).map(Cow::Borrowed).unwrap_or_else(|| Cow::Owned(get_mixed()));
 
+        rhs_is_never = rhs_type.is_never();
+
         result_type = combine_union_types(&non_null_lhs_type, &rhs_type, context.codebase, false);
     }
 
     artifacts.expression_types.insert(get_expression_range(binary), Rc::new(result_type));
 
+    if rhs_is_never {
+        let assertion_context = context.get_assertion_context_from_block(block_context);
+        let if_clauses =
+            get_formula(binary.lhs.span(), binary.lhs.span(), binary.lhs, assertion_context, artifacts).unwrap();
+
+        let conditional_context_clauses = if_clauses.clone().into_iter().map(Rc::new).collect::<Vec<_>>();
+
+        let mut if_scope = IfScope::new();
+        let (if_conditional_scope, _) =
+            conditional::analyze(context, block_context.clone(), artifacts, &mut if_scope, binary.lhs, false)?;
+
+        let mut conditionally_referenced_variable_ids = if_conditional_scope.conditionally_referenced_variable_ids;
+
+        let (reconcilable_if_types, active_if_types) = find_satisfying_assignments(
+            conditional_context_clauses.into_iter().map(|rc| (*rc).clone()).collect::<Vec<_>>().as_slice(),
+            Some(binary.lhs.span()),
+            &mut conditionally_referenced_variable_ids,
+        );
+
+        let mut changed_variable_ids = HashSet::default();
+
+        reconcile_keyed_types(
+            context,
+            &reconcilable_if_types,
+            active_if_types,
+            block_context,
+            &mut changed_variable_ids,
+            &conditionally_referenced_variable_ids,
+            &binary.lhs.span(),
+            false,
+            false,
+        );
+    }
+
     Ok(())
 }


Test patch:
diff --git a/crates/analyzer/tests/cases/issue_583.php b/crates/analyzer/tests/cases/issue_583.php
new file mode 100644
index 00000000..bbe9abc0
--- /dev/null
+++ b/crates/analyzer/tests/cases/issue_583.php
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @throws InvalidArgumentException
+ * @param array{currency: null|string} $data
+ */
+function x($data): void
+{
+    $data['currency'] ?? throw new \InvalidArgumentException('Currency is required.'); // @mago-expect analysis: unused-statement
+    accept_string($data['currency']);
+}
+
+function accept_string(string $_s): void {}
diff --git a/crates/analyzer/tests/mod.rs b/crates/analyzer/tests/mod.rs
index 58553224..78ce357a 100644
--- a/crates/analyzer/tests/mod.rs
+++ b/crates/analyzer/tests/mod.rs
@@ -266,6 +266,7 @@ test_case!(issue_549);
 test_case!(issue_551);
 test_case!(issue_577);
 test_case!(issue_574);
+test_case!(issue_583);
 test_case!(issue_586);
 test_case!(issue_596);
 test_case!(issue_598);