Skip to main content

mir_issues/
lib.rs

1use std::collections::HashSet;
2use std::fmt;
3use std::sync::Arc;
4
5use owo_colors::OwoColorize;
6use serde::{Deserialize, Serialize};
7
8// ---------------------------------------------------------------------------
9// Severity
10// ---------------------------------------------------------------------------
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
13pub enum Severity {
14    /// Only shown with `--show-info`
15    Info,
16    /// Warnings — shown at default level
17    Warning,
18    /// Errors — always shown; non-zero exit code
19    Error,
20}
21
22impl fmt::Display for Severity {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Severity::Info => write!(f, "info"),
26            Severity::Warning => write!(f, "warning"),
27            Severity::Error => write!(f, "error"),
28        }
29    }
30}
31
32// ---------------------------------------------------------------------------
33// Location
34// ---------------------------------------------------------------------------
35
36pub use mir_types::Location;
37
38// ---------------------------------------------------------------------------
39// IssueKind
40// ---------------------------------------------------------------------------
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[non_exhaustive]
44pub enum IssueKind {
45    // --- Undefined ----------------------------------------------------------
46    /// Emitted by `mir-analyzer/src/expr/variables.rs`.
47    /// Fixtures: `tests/fixtures/by-kind/invalid_scope/`.
48    /// Emitted by `mir-analyzer/src/call/static_call.rs`.
49    /// Fixtures: `tests/fixtures/by-kind/invalid_scope/self_non_static_invocation.phpt`.
50    NonStaticSelfCall { class: String, method: String },
51    InvalidScope {
52        /// `true` when inside a class but in a static method; `false` when outside a class.
53        in_class: bool,
54    },
55    /// Emitted by `mir-analyzer/src/expr/variables.rs`.
56    /// Fixtures: `tests/fixtures/by-kind/undefined_variable/`.
57    UndefinedVariable { name: String },
58    /// Emitted by `mir-analyzer/src/call/function.rs`.
59    /// Fixtures: `tests/fixtures/by-kind/undefined_function/`.
60    UndefinedFunction { name: String },
61    /// Emitted by `mir-analyzer/src/call/static_call.rs`.
62    /// Fixtures: `tests/fixtures/by-kind/undefined_method/`.
63    UndefinedMethod { class: String, method: String },
64    /// Emitted by `mir-analyzer/src/batch/mod.rs`.
65    /// Fixtures: `tests/fixtures/by-kind/undefined_class/`.
66    UndefinedClass { name: String },
67    /// Emitted by `mir-analyzer/src/expr/objects.rs`.
68    /// Fixtures: `tests/fixtures/by-kind/undefined_property/`.
69    UndefinedProperty { class: String, property: String },
70    /// Emitted by `mir-analyzer/src/expr/variables.rs`.
71    /// Fixtures: `tests/fixtures/by-kind/undefined_constant/`.
72    UndefinedConstant { name: String },
73    /// Emitted by `mir-analyzer/src/expr/objects.rs`.
74    /// Fixtures: `tests/fixtures/by-kind/invalid_argument/invalid_*_class_const_fetch*.phpt`.
75    InaccessibleClassConstant { class: String, constant: String },
76    /// Emitted by `mir-analyzer/src/expr/variables.rs`.
77    /// Fixtures: `tests/fixtures/by-kind/possibly_undefined_variable/`.
78    PossiblyUndefinedVariable { name: String },
79    /// Emitted by `mir-analyzer/src/body_analysis/mod.rs`.
80    /// Fixtures: `tests/fixtures/by-kind/undefined_trait/`.
81    UndefinedTrait { name: String },
82    /// Emitted when `parent::` is used in a class that has no parent.
83    /// Fixtures: `tests/fixtures/by-kind/undefined_class/no_parent*.phpt`.
84    ParentNotFound,
85    /// Emitted by `mir-analyzer/src/expr/objects.rs`.
86    /// Fixtures: `tests/fixtures/by-kind/invalid_string_class/`.
87    InvalidStringClass { actual: String },
88    /// Emitted by `mir-analyzer/src/call/args/types.rs` when an `interface-string`-typed
89    /// argument names a class or trait that exists but is not an interface.
90    /// Fixtures: `tests/fixtures/by-kind/invalid_argument/interface_string_*.phpt`.
91    NotAnInterface { name: String },
92
93    // --- Nullability --------------------------------------------------------
94    /// Emitted by `mir-analyzer/src/call/args.rs`.
95    /// Fixtures: `tests/fixtures/by-kind/null_argument/`.
96    NullArgument { param: String, fn_name: String },
97    /// Emitted by `mir-analyzer/src/expr/objects.rs`.
98    /// Fixtures: `tests/fixtures/by-kind/null_property_fetch/`.
99    NullPropertyFetch { property: String },
100    /// Emitted by `mir-analyzer/src/call/method.rs`.
101    /// Fixtures: `tests/fixtures/by-kind/null_method_call/`.
102    NullMethodCall { method: String },
103    /// Emitted by `mir-analyzer/src/expr/arrays.rs`.
104    /// Fixtures: `tests/fixtures/by-kind/null_array_access/`.
105    NullArrayAccess,
106    /// Emitted by `mir-analyzer/src/call/args.rs`.
107    /// Fixtures: `tests/fixtures/by-kind/possibly_null_argument/`.
108    PossiblyNullArgument { param: String, fn_name: String },
109    /// Emitted by `mir-analyzer/src/call/args.rs`.
110    /// Fixtures: `tests/fixtures/by-kind/possibly_invalid_argument/`.
111    PossiblyInvalidArgument {
112        param: String,
113        fn_name: String,
114        expected: String,
115        actual: String,
116    },
117    /// Emitted by `mir-analyzer/src/expr/objects.rs`.
118    /// Fixtures: `tests/fixtures/by-kind/possibly_null_property_fetch/`.
119    PossiblyNullPropertyFetch { property: String },
120    /// Emitted by `mir-analyzer/src/call/method.rs`.
121    /// Fixtures: `tests/fixtures/by-kind/possibly_null_method_call/`.
122    PossiblyNullMethodCall { method: String },
123    /// Emitted by `mir-analyzer/src/expr/arrays.rs`.
124    /// Fixtures: `tests/fixtures/by-kind/possibly_null_array_access/`.
125    PossiblyNullArrayAccess,
126    /// Emitted by `mir-analyzer/src/stmt/flow.rs`.
127    /// Fixtures: `tests/fixtures/by-kind/nullable_return_statement/`.
128    NullableReturnStatement { expected: String, actual: String },
129
130    // --- Type mismatches ----------------------------------------------------
131    /// Emitted by `mir-analyzer/src/stmt/flow.rs`.
132    /// Fixtures: `tests/fixtures/by-kind/invalid_return_type/`.
133    InvalidReturnType { expected: String, actual: String },
134    /// Emitted by `mir-analyzer/src/call/args.rs`.
135    /// Fixtures: `tests/fixtures/by-kind/invalid_argument/`.
136    InvalidArgument {
137        param: String,
138        fn_name: String,
139        expected: String,
140        actual: String,
141    },
142    /// Emitted by `mir-analyzer/src/call/callable.rs`.
143    /// Fixtures: `tests/fixtures/by-kind/too_few_arguments/`.
144    TooFewArguments {
145        fn_name: String,
146        expected: usize,
147        actual: usize,
148    },
149    /// Emitted by `mir-analyzer/src/call/function.rs`.
150    /// Fixtures: `tests/fixtures/by-kind/too_many_arguments/`.
151    TooManyArguments {
152        fn_name: String,
153        expected: usize,
154        actual: usize,
155    },
156    /// Emitted by `mir-analyzer/src/call/args.rs`.
157    /// Fixtures: `tests/fixtures/by-kind/invalid_named_argument/`.
158    InvalidNamedArgument { fn_name: String, name: String },
159    /// Emitted when a function/method tagged `@no-named-arguments` is called with named args.
160    /// Fixtures: `tests/fixtures/by-kind/invalid_named_argument/`.
161    InvalidNamedArguments { fn_name: String },
162    /// Emitted by `mir-analyzer/src/call/args.rs`.
163    /// Fixtures: `tests/fixtures/by-kind/invalid_pass_by_reference/`.
164    InvalidPassByReference { fn_name: String, param: String },
165    /// Emitted by `mir-analyzer/src/expr/objects.rs`.
166    /// Fixtures: `tests/fixtures/by-kind/invalid_property_fetch/bad_fetch.phpt`.
167    InvalidPropertyFetch { ty: String },
168    /// Emitted by `mir-analyzer/src/expr/arrays.rs`.
169    /// Fixtures: `tests/fixtures/by-kind/invalid_array_access/`.
170    InvalidArrayAccess { ty: String },
171    /// Emitted by `mir-analyzer/src/expr/arrays.rs`.
172    /// Fixtures: `tests/fixtures/by-kind/possibly_invalid_array_access/`.
173    PossiblyInvalidArrayAccess { ty: String },
174    /// Emitted by `mir-analyzer/src/expr/assignment.rs`.
175    /// Fixtures: `tests/fixtures/by-kind/invalid_array_assignment/`.
176    InvalidArrayAssignment { ty: String },
177    /// Emitted by `mir-analyzer/src/expr/assignment.rs`.
178    /// Fixtures: `tests/fixtures/by-kind/invalid_property_assignment/`.
179    InvalidPropertyAssignment {
180        property: String,
181        expected: String,
182        actual: String,
183    },
184    /// Emitted by `mir-analyzer/src/expr/casts.rs`.
185    /// Fixtures: `tests/fixtures/by-kind/invalid_cast/`.
186    InvalidCast { from: String, to: String },
187    /// Emitted by `mir-analyzer/src/call/static_call.rs`.
188    /// Fixtures: `tests/fixtures/by-kind/undefined_method/static_invocation*.phpt`.
189    InvalidStaticInvocation { class: String, method: String },
190    /// Emitted by `mir-analyzer/src/expr/binary.rs` and `unary.rs` for operations on
191    /// non-numeric or non-bitwise-compatible operands.
192    /// Fixtures: `tests/fixtures/by-kind/invalid_operand/`.
193    InvalidOperand {
194        op: String,
195        left: String,
196        right: String,
197    },
198    /// Emitted when a union-typed operand has some non-numeric/non-stringifiable members.
199    /// Fixtures: `tests/fixtures/by-kind/invalid_operand/`.
200    PossiblyInvalidOperand {
201        op: String,
202        left: String,
203        right: String,
204    },
205    /// Emitted when a divisor operand could be null (potential division by zero).
206    /// Fixtures: `tests/fixtures/by-kind/invalid_operand/`.
207    PossiblyNullOperand { op: String, ty: String },
208    /// Emitted when a divisor operand is DEFINITELY the literal `0` — an
209    /// unconditional runtime `DivisionByZeroError`.
210    /// Fixtures: `tests/fixtures/by-kind/invalid_operand/`.
211    DivisionByZero { op: String },
212    /// Emitted when `yield from` is used with a non-iterable object (no Traversable).
213    /// Fixtures: `tests/fixtures/by-kind/invalid_operand/`.
214    RawObjectIteration { ty: String },
215    /// Emitted when `yield from` might be used with a non-iterable object.
216    /// Fixtures: `tests/fixtures/by-kind/invalid_operand/`.
217    PossiblyRawObjectIteration { ty: String },
218    /// Emitted by `mir-analyzer/src/body_analysis/mod.rs`.
219    /// Fixtures: `tests/fixtures/by-kind/mismatching_docblock_return_type/`.
220    MismatchingDocblockReturnType { declared: String, inferred: String },
221    /// Emitted by `mir-analyzer/src/body_analysis/mod.rs`.
222    /// Fixtures: `tests/fixtures/by-kind/mismatching_docblock_param_type/`.
223    MismatchingDocblockParamType {
224        param: String,
225        declared: String,
226        inferred: String,
227    },
228    /// Emitted by `mir-analyzer/src/stmt/mod.rs`.
229    /// Fixtures: `tests/fixtures/by-kind/type_check_mismatch/`.
230    TypeCheckMismatch {
231        var: String,
232        expected: String,
233        actual: String,
234    },
235
236    /// Emitted by `@trace $var` docblock annotation. Shows inferred type.
237    /// Fixtures: `tests/fixtures/by-kind/trace/`.
238    Trace { variable: String, type_info: String },
239
240    // --- Array issues -------------------------------------------------------
241    /// Emitted by `mir-analyzer/src/expr/arrays.rs`.
242    /// Fixtures: `tests/fixtures/by-kind/invalid_array_offset/`.
243    InvalidArrayOffset { expected: String, actual: String },
244    /// Emitted by `mir-analyzer/src/expr/arrays.rs` when a TKeyedArray is accessed with
245    /// a literal key that does not exist in the shape.
246    /// Fixtures: `tests/fixtures/by-kind/invalid_array_offset/`.
247    NonExistentArrayOffset { key: String },
248    /// Emitted by `mir-analyzer/src/expr/assignment.rs`.
249    /// Fixtures: `tests/fixtures/by-kind/possibly_invalid_array_offset/`.
250    PossiblyInvalidArrayOffset { expected: String, actual: String },
251    /// Emitted by `mir-analyzer/src/expr/arrays.rs` when an array literal repeats
252    /// the same key — the earlier entry is silently overwritten at runtime,
253    /// almost always a copy-paste mistake.
254    /// Fixtures: `tests/fixtures/by-kind/duplicate_array_key/`.
255    DuplicateArrayKey { key: String },
256
257    // --- Redundancy ---------------------------------------------------------
258    /// Emitted by `mir-analyzer/src/stmt/control_flow.rs`.
259    /// Fixtures: `tests/fixtures/by-kind/redundant_condition/`.
260    RedundantCondition { ty: String },
261    /// Emitted by `mir-analyzer/src/expr/casts.rs`.
262    /// Fixtures: `tests/fixtures/by-kind/redundant_cast/`.
263    RedundantCast { from: String, to: String },
264    /// Emitted by `mir-analyzer/src/stmt/mod.rs`.
265    /// Fixtures: `tests/fixtures/by-kind/unnecessary_var_annotation/`.
266    UnnecessaryVarAnnotation { var: String },
267    /// Emitted by `mir-analyzer/src/stmt/control_flow.rs` and `mir-analyzer/src/expr/conditional.rs`.
268    /// Fixtures: `tests/fixtures/by-kind/type_does_not_contain_type/`.
269    TypeDoesNotContainType { left: String, right: String },
270    /// Emitted by `mir-analyzer/src/stmt/control_flow.rs` and `mir-analyzer/src/expr/conditional.rs`.
271    /// Fixtures: `tests/fixtures/by-kind/paradoxical_condition/`.
272    ParadoxicalCondition { value: String },
273    /// A docblock-declared type makes a subsequent assertion or comparison
274    /// impossible (e.g. `assert($a < 4)` on a `@param int<5, max> $a`).
275    /// Emitted by `mir-analyzer/src/narrowing.rs`.
276    /// Fixtures: `tests/fixtures/by-kind/docblock_type_contradiction/`.
277    DocblockTypeContradiction { expr: String, declared: String },
278    /// A `===` or `!==` comparison between two types that can never be strictly
279    /// equal — e.g. `$int === $string` or `$obj !== null` where `$obj` is a
280    /// non-nullable typed value.
281    /// Emitted by `mir-analyzer/src/expr/binary.rs`.
282    /// Fixtures: `tests/fixtures/by-kind/impossible_identical_comparison/`.
283    ImpossibleIdenticalComparison {
284        op: String,
285        left: String,
286        right: String,
287    },
288    /// A `==` or `!=` comparison between two types that can never be loosely
289    /// equal in PHP — e.g. `$obj == null`, `$arr == "foo"`, or a non-empty
290    /// array `== false`.  PHP's type-juggling rules make these always false (or
291    /// always true for `!=`), which almost certainly indicates a logic bug.
292    /// Emitted by `mir-analyzer/src/expr/binary.rs`.
293    /// Fixtures: `tests/fixtures/by-kind/impossible_loose_comparison/`.
294    ImpossibleLooseComparison {
295        op: String,
296        left: String,
297        right: String,
298    },
299    /// A `switch`/`match` arm that can never be reached given the subject's
300    /// inferred type — most often a `gettype()` arm tested against a string
301    /// that `gettype()` never returns (e.g. `case "int"` — it returns
302    /// `"integer"`).
303    /// Emitted by `mir-analyzer/src/stmt/control_flow.rs` and `mir-analyzer/src/expr/conditional.rs`.
304    /// Fixtures: `tests/fixtures/by-kind/unevaluated_code/`.
305    UnevaluatedCode { reason: String },
306
307    // --- Dead code ----------------------------------------------------------
308    /// Emitted by `mir-analyzer/src/diagnostics.rs`.
309    /// Fixtures: `tests/fixtures/by-kind/unused_variable/`.
310    UnusedVariable { name: String },
311    /// Emitted by `mir-analyzer/src/diagnostics.rs`.
312    /// Fixtures: `tests/fixtures/by-kind/unused_param/`.
313    UnusedParam { name: String },
314    /// Emitted by `mir-analyzer/src/stmt/mod.rs`.
315    /// Fixtures: `tests/fixtures/by-kind/unreachable_code/`.
316    UnreachableCode,
317    /// Emitted by `mir-analyzer/src/expr/conditional.rs`.
318    /// Fixtures: `tests/fixtures/by-kind/unreachable_code/`.
319    UnhandledMatchCondition { detail: String },
320    /// Emitted by `mir-analyzer/src/dead_code.rs`.
321    /// Fixtures: `tests/fixtures/by-kind/unused_method/`.
322    UnusedMethod { class: String, method: String },
323    /// Emitted by `mir-analyzer/src/dead_code.rs`.
324    /// Fixtures: `tests/fixtures/by-kind/unused_property/`.
325    UnusedProperty { class: String, property: String },
326    /// Emitted by `mir-analyzer/src/dead_code.rs`.
327    /// Fixtures: `tests/fixtures/by-kind/unused_function/`.
328    UnusedFunction { name: String },
329    /// Emitted by `mir-analyzer/src/diagnostics.rs`.
330    /// Fixtures: `tests/fixtures/by-kind/unused_foreach_value/`.
331    UnusedForeachValue { name: String },
332    /// Emitted by `mir-analyzer/src/dead_code.rs`.
333    /// Fixtures: `tests/fixtures/by-kind/unused_class/`.
334    UnusedClass { class: String },
335    /// Emitted by `mir-analyzer/src/batch/mod.rs` when a `@psalm-suppress` /
336    /// `@mir-suppress` / `@suppress` annotation does not match any actual issue.
337    /// Fixtures: `tests/fixtures/by-kind/unused_suppress/`.
338    UnusedSuppress { kind: String },
339
340    /// Emitted by `mir-analyzer/src/call/args/types.rs`.
341    /// Fixtures: `tests/fixtures/by-kind/argument_type_coercion/`.
342    ArgumentTypeCoercion {
343        param: String,
344        fn_name: String,
345        expected: String,
346        actual: String,
347    },
348
349    /// Emitted by `mir-analyzer/src/expr/assignment.rs`.
350    /// Fixtures: `tests/fixtures/by-kind/property_type_coercion/`.
351    PropertyTypeCoercion {
352        property: String,
353        expected: String,
354        actual: String,
355    },
356
357    // --- Purity -------------------------------------------------------------
358    /// Emitted when a @pure function assigns to a parameter's property.
359    ImpurePropertyAssignment { property: String },
360    /// Emitted when a @pure function calls an impure method on a parameter.
361    ImpureMethodCall { method: String },
362    /// Emitted when a @pure function uses a global variable.
363    ImpureGlobalVariable { variable: String },
364    /// Emitted when a @pure function uses a static variable.
365    ImpureStaticVariable { variable: String },
366    /// Emitted when a @pure function assigns to a class static property
367    /// (`self::$x = ...`, `Foo::$x = ...`) — static properties are shared
368    /// external state, same as a global variable.
369    ImpureStaticPropertyAssignment { class: String, property: String },
370    /// Emitted by `mir-analyzer/src/call/function.rs` when a `@pure` function calls a
371    /// non-pure named function.
372    /// Fixtures: `tests/fixtures/by-kind/impure_function_call/`.
373    ImpureFunctionCall { fn_name: String },
374    /// Emitted when a non-constructor method of a `@psalm-immutable` class assigns to a
375    /// `$this` property.
376    /// Fixtures: `tests/fixtures/by-kind/immutable_property_modification/`.
377    ImmutablePropertyModification { property: String },
378
379    // --- Readonly -----------------------------------------------------------
380    /// Emitted by `mir-analyzer/src/expr/assignment.rs`.
381    /// Fixtures: `tests/fixtures/by-kind/readonly_property_assignment/`.
382    ReadonlyPropertyAssignment { class: String, property: String },
383
384    // --- Inheritance --------------------------------------------------------
385    /// Emitted by `mir-analyzer/src/class/mod.rs`.
386    /// Fixtures: `tests/fixtures/by-kind/unimplemented_abstract_method/`.
387    UnimplementedAbstractMethod { class: String, method: String },
388    /// Emitted by `mir-analyzer/src/class/mod.rs`.
389    /// Fixtures: `tests/fixtures/by-kind/unimplemented_interface_method/`.
390    UnimplementedInterfaceMethod {
391        class: String,
392        interface: String,
393        method: String,
394    },
395    /// Emitted by `mir-analyzer/src/class/mod.rs`.
396    /// Fixtures: `tests/fixtures/by-kind/method_signature_mismatch/`.
397    MethodSignatureMismatch {
398        class: String,
399        method: String,
400        detail: String,
401    },
402    /// Emitted by `mir-analyzer/src/class/mod.rs`.
403    /// Fixtures: `tests/fixtures/by-kind/overridden_method_access/`.
404    OverriddenMethodAccess { class: String, method: String },
405    /// Emitted by `mir-analyzer/src/class/mod.rs`.
406    /// Fixtures: `tests/fixtures/by-kind/overridden_property_access/`.
407    OverriddenPropertyAccess { class: String, property: String },
408    /// Emitted by `mir-analyzer/src/class/overrides.rs`.
409    /// Fixtures: `tests/fixtures/by-kind/property_type_redeclaration_mismatch/`.
410    PropertyTypeRedeclarationMismatch {
411        class: String,
412        property: String,
413        expected: String,
414        actual: String,
415    },
416    /// Emitted by `mir-analyzer/src/class/overrides.rs`.
417    /// Fixtures: `tests/fixtures/by-kind/readonly_property_redeclaration_mismatch/`.
418    ReadonlyPropertyRedeclarationMismatch {
419        parent_class: String,
420        class: String,
421        property: String,
422        /// True when the parent's property is readonly and the child drops it;
423        /// false when the parent's property is non-readonly and the child adds it.
424        parent_readonly: bool,
425    },
426    /// Emitted by `mir-analyzer/src/class/overrides.rs`.
427    /// Fixtures: `tests/fixtures/by-kind/static_property_redeclaration_mismatch/`.
428    StaticPropertyRedeclarationMismatch {
429        parent_class: String,
430        class: String,
431        property: String,
432        /// True when the parent's property is static and the child redeclares it as
433        /// instance; false when the parent's is instance and the child redeclares it static.
434        parent_static: bool,
435    },
436    /// Emitted by `mir-analyzer/src/collector/enum.rs`.
437    /// Fixtures: `tests/fixtures/by-kind/backed_enum_case_type_mismatch/`.
438    BackedEnumCaseTypeMismatch {
439        enum_name: String,
440        case_name: String,
441        expected: String,
442        actual: String,
443    },
444    /// Emitted by `mir-analyzer/src/call/method.rs`.
445    /// Fixtures: `tests/fixtures/by-kind/undefined_method/direct_constructor_call*.phpt`.
446    DirectConstructorCall { class: String },
447    /// Emitted by `mir-analyzer/src/class/mod.rs`.
448    /// Fixtures: `tests/fixtures/by-kind/invalid_extend_class/`.
449    InvalidExtendClass { parent: String, child: String },
450    /// Emitted by `mir-analyzer/src/class/mod.rs`.
451    /// Fixtures: `tests/fixtures/by-kind/final_method_overridden/`.
452    FinalMethodOverridden {
453        class: String,
454        method: String,
455        parent: String,
456    },
457    /// Emitted by `mir-analyzer/src/expr/objects.rs`.
458    /// Fixtures: `tests/fixtures/by-kind/abstract_instantiation/`.
459    AbstractInstantiation { class: String },
460    /// Emitted by `mir-analyzer/src/call/static_call.rs`.
461    /// Fixtures: `tests/fixtures/by-kind/abstract_instantiation/prevent_abstract_method_call.phpt`.
462    AbstractMethodCall { class: String, method: String },
463    /// Emitted by `mir-analyzer/src/expr/objects.rs`.
464    /// Fixtures: `tests/fixtures/by-kind/abstract_instantiation/interface_instantiation.phpt`.
465    InterfaceInstantiation { class: String },
466    /// Emitted by `mir-analyzer/src/class/mod.rs` when `#[Override]` is declared
467    /// but no overridable parent method exists.
468    /// Fixtures: `tests/fixtures/by-kind/method_signature_mismatch/`.
469    InvalidOverride {
470        class: String,
471        method: String,
472        detail: String,
473    },
474
475    // --- Security (taint) ---------------------------------------------------
476    /// Not yet emitted (generic taint sink; specific sinks use `TaintedHtml`, `TaintedSql`, `TaintedShell`).
477    /// No fixtures yet.
478    TaintedInput { sink: String },
479    /// Emitted by `mir-analyzer/src/call/function.rs`.
480    /// Fixtures: `tests/fixtures/by-kind/tainted_html/`.
481    TaintedHtml,
482    /// Emitted by `mir-analyzer/src/call/function.rs`.
483    /// Fixtures: `tests/fixtures/by-kind/tainted_sql/`.
484    TaintedSql,
485    /// Emitted by `mir-analyzer/src/call/function.rs`.
486    /// Fixtures: `tests/fixtures/by-kind/tainted_shell/`.
487    TaintedShell,
488    /// Emitted by `mir-analyzer/src/call/method.rs` when a tainted value reaches a
489    /// `@taint-sink llm_prompt` annotated parameter.
490    /// Fixtures: `tests/fixtures/by-kind/tainted_llm_prompt/`.
491    TaintedLlmPrompt,
492
493    // --- Generics -----------------------------------------------------------
494    /// Emitted by `mir-analyzer/src/call/function.rs`.
495    /// Fixtures: `tests/fixtures/by-kind/invalid_template_param/`.
496    InvalidTemplateParam {
497        name: String,
498        expected_bound: String,
499        actual: String,
500    },
501    /// Emitted by `mir-analyzer/src/call/method.rs`.
502    /// Fixtures: `tests/fixtures/by-kind/shadowed_template_param/`.
503    ShadowedTemplateParam { name: String },
504    /// A method annotated `@if-this-is X<Y>` was called on a receiver whose
505    /// type does not satisfy that constraint.
506    /// Emitted by `mir-analyzer/src/call/method.rs`.
507    /// Fixtures: `tests/fixtures/by-kind/if_this_is_mismatch/`.
508    IfThisIsMismatch {
509        class: String,
510        method: String,
511        expected: String,
512        actual: String,
513    },
514
515    // --- Other --------------------------------------------------------------
516    /// Emitted by `mir-analyzer/src/call/function.rs`.
517    /// Fixtures: `tests/fixtures/by-kind/deprecated_call/`.
518    DeprecatedCall {
519        name: String,
520        message: Option<Arc<str>>,
521    },
522    /// Emitted by `mir-analyzer/src/expr/objects.rs`.
523    /// Fixtures: `tests/fixtures/by-kind/undefined_property/deprecated_property_*.phpt`.
524    DeprecatedProperty {
525        class: String,
526        property: String,
527        message: Option<Arc<str>>,
528    },
529    /// Emitted by `mir-analyzer/src/expr/objects.rs`.
530    /// Fixtures: `tests/fixtures/by-kind/deprecated_call/deprecated_class_const_fetch*.phpt`.
531    DeprecatedConstant {
532        class: String,
533        constant: String,
534        message: Option<Arc<str>>,
535    },
536    /// Emitted by `mir-analyzer/src/class/mod.rs`.
537    /// Fixtures: `tests/fixtures/by-kind/deprecated_interface/`.
538    DeprecatedInterface {
539        name: String,
540        message: Option<Arc<str>>,
541    },
542    /// Emitted by `mir-analyzer/src/class/mod.rs`.
543    /// Fixtures: `tests/fixtures/by-kind/deprecated_trait/`.
544    DeprecatedTrait {
545        name: String,
546        message: Option<Arc<str>>,
547    },
548    /// Emitted by `mir-analyzer/src/call/method.rs`.
549    /// Fixtures: `tests/fixtures/by-kind/deprecated_method_call/`.
550    DeprecatedMethodCall {
551        class: String,
552        method: String,
553        message: Option<Arc<str>>,
554    },
555    /// Emitted by `mir-analyzer/src/call/method.rs`.
556    /// Fixtures: `tests/fixtures/by-kind/deprecated_method/`.
557    DeprecatedMethod {
558        class: String,
559        method: String,
560        message: Option<Arc<str>>,
561    },
562    /// Emitted by `mir-analyzer/src/class/mod.rs`.
563    /// Fixtures: `tests/fixtures/by-kind/deprecated_class/`.
564    DeprecatedClass {
565        name: String,
566        message: Option<Arc<str>>,
567    },
568    /// Emitted by `mir-analyzer/src/call/method.rs`.
569    /// Fixtures: `tests/fixtures/by-kind/internal_method/`.
570    InternalMethod { class: String, method: String },
571    /// Emitted by `mir-analyzer/src/body_analysis/mod.rs`.
572    /// Fixtures: `tests/fixtures/by-kind/missing_return_type/`.
573    MissingReturnType { fn_name: String },
574    /// Emitted by `mir-analyzer/src/expr/closures.rs`.
575    /// Fixtures: `tests/fixtures/by-kind/missing_closure_return_type/`.
576    MissingClosureReturnType,
577    /// Emitted by `mir-analyzer/src/body_analysis/mod.rs`.
578    /// Fixtures: `tests/fixtures/by-kind/missing_param_type/`.
579    MissingParamType { fn_name: String, param: String },
580    /// Emitted by `mir-analyzer/src/body_analysis/mod.rs`.
581    /// Fixtures: `tests/fixtures/by-kind/missing_param_type/` (property variants).
582    MissingPropertyType { class: String, property: String },
583    /// Emitted by `mir-analyzer/src/stmt/flow.rs`.
584    /// Fixtures: `tests/fixtures/by-kind/invalid_throw/`.
585    InvalidThrow { ty: String },
586    /// Emitted by `mir-analyzer/src/stmt/control_flow.rs`.
587    /// Fixtures: `tests/fixtures/by-kind/invalid_catch/`.
588    InvalidCatch { ty: String },
589    /// Emitted by `mir-analyzer/src/stmt/control_flow.rs` when a `catch` type is a
590    /// subtype of (or identical to) a type already caught by an earlier `catch`
591    /// clause on the same `try` — the later block can never run.
592    /// Fixtures: `tests/fixtures/by-kind/invalid_catch/`.
593    UnreachableCatch { ty: String, shadowed_by: String },
594    /// Emitted by `mir-analyzer/src/stmt/flow.rs`.
595    /// Fixtures: `tests/fixtures/by-kind/missing_throws_docblock/`.
596    MissingThrowsDocblock { class: String },
597    /// Emitted by `mir-analyzer/src/stmt/expressions.rs`.
598    /// Fixtures: `tests/fixtures/by-kind/implicit_to_string_cast/`.
599    ImplicitToStringCast { class: String },
600    /// Emitted by `mir-analyzer/src/call/args.rs`.
601    /// Fixtures: `tests/fixtures/by-kind/implicit_float_to_int_cast/`.
602    ImplicitFloatToIntCast { from: String },
603    /// Emitted by `mir-analyzer/src/parser/mod.rs`.
604    /// Fixtures: `tests/fixtures/by-kind/parse_error/`.
605    ParseError { message: String },
606    /// Emitted by `mir-analyzer/src/collector/annotation.rs`.
607    /// Fixtures: `tests/fixtures/by-kind/invalid_docblock/`.
608    InvalidDocblock { message: String },
609    /// Emitted by `mir-analyzer/src/call/args/types.rs`.
610    /// Fixtures: `tests/fixtures/by-kind/mixed_argument/`.
611    MixedArgument { param: String, fn_name: String },
612    /// Emitted by `mir-analyzer/src/expr/assignment.rs` and `mir-analyzer/src/stmt/control_flow.rs`.
613    /// Fixtures: `tests/fixtures/by-kind/mixed_assignment/`.
614    MixedAssignment { var: String },
615    /// Emitted by `mir-analyzer/src/call/method.rs`.
616    /// Fixtures: `tests/fixtures/by-kind/mixed_method_call/`.
617    MixedMethodCall { method: String },
618    /// Emitted when a PHP reference assignment is used (e.g. `$b = &$arr[$x]`).
619    /// Fixtures: `tests/fixtures/by-kind/unsupported_reference_usage/`.
620    UnsupportedReferenceUsage,
621    /// Emitted when a property is accessed on an interface that has `@seal-properties`
622    /// but the property is not declared with `@property`/`@property-read`/`@property-write`.
623    /// Fixtures: `tests/fixtures/by-kind/undefined_property/magic_interface_*.phpt`.
624    NoInterfaceProperties { property: String },
625    /// Emitted when a class referenced only in a docblock (`@return`, `@param`, etc.)
626    /// does not exist.
627    /// Fixtures: `tests/fixtures/by-kind/mixed_clone/missing_class.phpt`.
628    UndefinedDocblockClass { name: String },
629    /// Emitted when a class with non-nullable uninitialized properties has no constructor.
630    /// Fixtures: `tests/fixtures/by-kind/missing_constructor/`.
631    MissingConstructor { class: String },
632    /// Emitted by `mir-analyzer/src/call/function.rs` when a dynamic call target is mixed.
633    /// Fixtures: `tests/fixtures/by-kind/mixed_function_call/`.
634    MixedFunctionCall,
635    /// Emitted by `mir-analyzer/src/stmt/flow.rs`.
636    /// Fixtures: `tests/fixtures/by-kind/mixed_return_statement/`.
637    MixedReturnStatement { declared: String },
638    /// Emitted by `mir-analyzer/src/expr/objects.rs`.
639    /// Fixtures: `tests/fixtures/by-kind/mixed_property_fetch/`.
640    MixedPropertyFetch { property: String },
641    /// Emitted by `mir-analyzer/src/expr/assignment.rs`.
642    /// Fixtures: `tests/fixtures/by-kind/mixed_property_assignment/`.
643    MixedPropertyAssignment { property: String },
644    /// Emitted by `mir-analyzer/src/expr/arrays.rs`.
645    /// Fixtures: `tests/fixtures/by-kind/mixed_array_access/`.
646    MixedArrayAccess,
647    /// Emitted by `mir-analyzer/src/expr/arrays.rs`.
648    /// Fixtures: `tests/fixtures/by-kind/mixed_array_offset/`.
649    MixedArrayOffset,
650    /// Emitted by `mir-analyzer/src/expr/mod.rs`.
651    /// Fixtures: `tests/fixtures/by-kind/mixed_clone/`.
652    MixedClone,
653    /// `clone` of a value that is definitely not an object (e.g. `int`, `string`).
654    /// Emitted by `mir-analyzer/src/expr/mod.rs`.
655    /// Fixtures: `tests/fixtures/by-kind/mixed_clone/`.
656    InvalidClone { ty: String },
657    /// `clone` of a union where some members are not objects (e.g. `int|Exception`).
658    /// Emitted by `mir-analyzer/src/expr/mod.rs`.
659    /// Fixtures: `tests/fixtures/by-kind/mixed_clone/`.
660    PossiblyInvalidClone { ty: String },
661    /// A `__toString` method that does not return a `string`.
662    /// Emitted by `mir-analyzer/src/body_analysis/mod.rs`.
663    /// Fixtures: `tests/fixtures/by-kind/implicit_to_string_cast/`.
664    InvalidToString { class: String },
665    /// Emitted by `mir-analyzer/src/class/mod.rs`.
666    /// Fixtures: `tests/fixtures/by-kind/circular_inheritance/`.
667    CircularInheritance { class: String },
668
669    // --- Trait constraints --------------------------------------------------
670    /// Emitted by `mir-analyzer/src/body_analysis/mod.rs`.
671    /// Fixtures: `tests/fixtures/by-kind/invalid_trait_use/`.
672    InvalidTraitUse { trait_name: String, reason: String },
673    /// Emitted by `mir-analyzer/src/expr/mod.rs` and `mir-analyzer/src/call/function.rs`.
674    /// Fixtures: `tests/fixtures/by-kind/invalid_operand/` (var_dump, shell_exec, backtick).
675    ForbiddenCode { message: String },
676
677    // --- Attribute validation -----------------------------------------------
678    /// Emitted by `mir-analyzer/src/attributes.rs`.
679    /// Fixtures: `tests/fixtures/by-kind/invalid_attribute/`.
680    InvalidAttribute { message: String },
681    /// Emitted by `mir-analyzer/src/attributes.rs`.
682    /// Fixtures: `tests/fixtures/by-kind/undefined_class/missing_attribute_on_*.phpt`.
683    UndefinedAttributeClass { name: String },
684
685    // --- Case sensitivity (PHP 8.6 deprecation) -----------------------------
686    /// Emitted by `mir-analyzer/src/call/function.rs`.
687    /// Fixtures: `tests/fixtures/by-kind/wrong_case_function/`.
688    WrongCaseFunction { used: String, canonical: String },
689    /// Emitted by `mir-analyzer/src/call/method.rs` and `src/call/static_call.rs`.
690    /// Fixtures: `tests/fixtures/by-kind/wrong_case_method/`.
691    WrongCaseMethod {
692        class: String,
693        used: String,
694        canonical: String,
695    },
696    /// Emitted by `mir-analyzer/src/expr/objects.rs` and `src/call/static_call.rs`.
697    /// Fixtures: `tests/fixtures/by-kind/wrong_case_class/`.
698    WrongCaseClass { used: String, canonical: String },
699    /// Emitted by `mir-analyzer/src/body_analysis/mod.rs`.
700    /// Fixtures: `tests/fixtures/by-kind/invalid_argument/class_redefinition*.phpt`.
701    DuplicateClass { name: String },
702    /// Emitted by `mir-analyzer/src/body_analysis/mod.rs`.
703    /// Fixtures: `tests/fixtures/by-kind/invalid_argument/interface_redefinition*.phpt`.
704    DuplicateInterface { name: String },
705    /// Emitted by `mir-analyzer/src/body_analysis/mod.rs`.
706    /// Fixtures: `tests/fixtures/by-kind/invalid_argument/trait_redefinition*.phpt`.
707    DuplicateTrait { name: String },
708    /// Emitted by `mir-analyzer/src/body_analysis/mod.rs`.
709    /// Fixtures: `tests/fixtures/by-kind/invalid_argument/enum_redefinition*.phpt`.
710    DuplicateEnum { name: String },
711    /// Emitted by `mir-analyzer/src/body_analysis/mod.rs`.
712    /// Fixtures: `tests/fixtures/by-kind/invalid_argument/function_redefinition*.phpt`.
713    DuplicateFunction { name: String },
714}
715
716fn append_deprecation_message(base: String, message: &Option<Arc<str>>) -> String {
717    match message.as_deref().filter(|m| !m.is_empty()) {
718        Some(msg) => format!("{base}: {msg}"),
719        None => base,
720    }
721}
722
723impl IssueKind {
724    /// Default severity for this issue kind.
725    pub fn default_severity(&self) -> Severity {
726        match self {
727            // Errors (always blocking)
728            IssueKind::NonStaticSelfCall { .. }
729            | IssueKind::DirectConstructorCall { .. }
730            | IssueKind::InvalidScope { .. }
731            | IssueKind::UndefinedVariable { .. }
732            | IssueKind::UndefinedFunction { .. }
733            | IssueKind::UndefinedMethod { .. }
734            | IssueKind::UndefinedClass { .. }
735            | IssueKind::NotAnInterface { .. }
736            | IssueKind::UndefinedConstant { .. }
737            | IssueKind::InaccessibleClassConstant { .. }
738            | IssueKind::InvalidReturnType { .. }
739            | IssueKind::InvalidArgument { .. }
740            | IssueKind::TooFewArguments { .. }
741            | IssueKind::TooManyArguments { .. }
742            | IssueKind::InvalidNamedArgument { .. }
743            | IssueKind::InvalidNamedArguments { .. }
744            | IssueKind::InvalidPassByReference { .. }
745            | IssueKind::InvalidThrow { .. }
746            | IssueKind::InvalidCatch { .. }
747            | IssueKind::InvalidStaticInvocation { .. }
748            | IssueKind::UnimplementedAbstractMethod { .. }
749            | IssueKind::UnimplementedInterfaceMethod { .. }
750            | IssueKind::MethodSignatureMismatch { .. }
751            | IssueKind::InvalidExtendClass { .. }
752            | IssueKind::FinalMethodOverridden { .. }
753            | IssueKind::AbstractInstantiation { .. }
754            | IssueKind::AbstractMethodCall { .. }
755            | IssueKind::InterfaceInstantiation { .. }
756            | IssueKind::InvalidOverride { .. }
757            | IssueKind::InvalidTemplateParam { .. }
758            | IssueKind::ReadonlyPropertyAssignment { .. }
759            | IssueKind::ParseError { .. }
760            | IssueKind::TaintedInput { .. }
761            | IssueKind::TaintedHtml
762            | IssueKind::TaintedSql
763            | IssueKind::TaintedShell
764            | IssueKind::TaintedLlmPrompt
765            | IssueKind::CircularInheritance { .. }
766            | IssueKind::InvalidTraitUse { .. }
767            | IssueKind::UndefinedTrait { .. }
768            | IssueKind::InvalidClone { .. }
769            | IssueKind::InvalidToString { .. }
770            | IssueKind::TypeCheckMismatch { .. }
771            | IssueKind::PropertyTypeRedeclarationMismatch { .. }
772            | IssueKind::ReadonlyPropertyRedeclarationMismatch { .. }
773            | IssueKind::StaticPropertyRedeclarationMismatch { .. }
774            | IssueKind::BackedEnumCaseTypeMismatch { .. }
775            | IssueKind::DivisionByZero { .. }
776            | IssueKind::ParentNotFound => Severity::Error,
777            IssueKind::Trace { .. } => Severity::Info,
778
779            // Warnings (shown at default error level)
780            IssueKind::NullArgument { .. }
781            | IssueKind::NullPropertyFetch { .. }
782            | IssueKind::NullMethodCall { .. }
783            | IssueKind::NullArrayAccess
784            | IssueKind::NullableReturnStatement { .. }
785            | IssueKind::InvalidPropertyFetch { .. }
786            | IssueKind::InvalidArrayAccess { .. }
787            | IssueKind::InvalidArrayAssignment { .. }
788            | IssueKind::InvalidPropertyAssignment { .. }
789            | IssueKind::InvalidArrayOffset { .. }
790            | IssueKind::NonExistentArrayOffset { .. }
791            | IssueKind::PossiblyInvalidArrayOffset { .. }
792            | IssueKind::UndefinedProperty { .. }
793            | IssueKind::InvalidOperand { .. }
794            | IssueKind::OverriddenMethodAccess { .. }
795            | IssueKind::OverriddenPropertyAccess { .. }
796            | IssueKind::ImplicitToStringCast { .. }
797            | IssueKind::ImplicitFloatToIntCast { .. }
798            | IssueKind::UnusedVariable { .. }
799            | IssueKind::UnusedForeachValue { .. }
800            | IssueKind::ImpurePropertyAssignment { .. }
801            | IssueKind::ImpureMethodCall { .. }
802            | IssueKind::ImpureGlobalVariable { .. }
803            | IssueKind::ImpureStaticVariable { .. }
804            | IssueKind::ImpureStaticPropertyAssignment { .. }
805            | IssueKind::ImpureFunctionCall { .. }
806            | IssueKind::ImmutablePropertyModification { .. }
807            | IssueKind::UnsupportedReferenceUsage
808            | IssueKind::ParadoxicalCondition { .. }
809            | IssueKind::UnhandledMatchCondition { .. }
810            | IssueKind::InvalidStringClass { .. }
811            | IssueKind::ImpossibleIdenticalComparison { .. }
812            | IssueKind::ImpossibleLooseComparison { .. }
813            | IssueKind::DuplicateArrayKey { .. }
814            | IssueKind::ForbiddenCode { .. } => Severity::Warning,
815
816            // PossiblyUndefined: shown at default error level (same as Warning)
817            IssueKind::PossiblyUndefinedVariable { .. } => Severity::Warning,
818
819            // Possibly-null / possibly-invalid (only shown in strict mode, level ≥ 7)
820            IssueKind::PossiblyNullArgument { .. }
821            | IssueKind::PossiblyInvalidArgument { .. }
822            | IssueKind::PossiblyNullPropertyFetch { .. }
823            | IssueKind::PossiblyNullMethodCall { .. }
824            | IssueKind::PossiblyNullArrayAccess
825            | IssueKind::PossiblyInvalidArrayAccess { .. }
826            | IssueKind::PossiblyInvalidClone { .. }
827            | IssueKind::PossiblyInvalidOperand { .. }
828            | IssueKind::PossiblyNullOperand { .. }
829            | IssueKind::PossiblyRawObjectIteration { .. } => Severity::Info,
830
831            IssueKind::RawObjectIteration { .. } => Severity::Warning,
832
833            // Info
834            IssueKind::RedundantCondition { .. }
835            | IssueKind::RedundantCast { .. }
836            | IssueKind::UnnecessaryVarAnnotation { .. }
837            | IssueKind::TypeDoesNotContainType { .. }
838            | IssueKind::DocblockTypeContradiction { .. }
839            | IssueKind::UnevaluatedCode { .. }
840            | IssueKind::IfThisIsMismatch { .. }
841            | IssueKind::UnusedParam { .. }
842            | IssueKind::UnreachableCode
843            | IssueKind::UnreachableCatch { .. }
844            | IssueKind::UnusedMethod { .. }
845            | IssueKind::UnusedProperty { .. }
846            | IssueKind::UnusedFunction { .. }
847            | IssueKind::UnusedClass { .. }
848            | IssueKind::UnusedSuppress { .. }
849            | IssueKind::ArgumentTypeCoercion { .. }
850            | IssueKind::PropertyTypeCoercion { .. }
851            | IssueKind::DeprecatedCall { .. }
852            | IssueKind::DeprecatedProperty { .. }
853            | IssueKind::DeprecatedConstant { .. }
854            | IssueKind::DeprecatedInterface { .. }
855            | IssueKind::DeprecatedTrait { .. }
856            | IssueKind::DeprecatedMethodCall { .. }
857            | IssueKind::DeprecatedMethod { .. }
858            | IssueKind::DeprecatedClass { .. }
859            | IssueKind::InternalMethod { .. }
860            | IssueKind::MissingReturnType { .. }
861            | IssueKind::MissingClosureReturnType
862            | IssueKind::MissingParamType { .. }
863            | IssueKind::MissingPropertyType { .. }
864            | IssueKind::MismatchingDocblockReturnType { .. }
865            | IssueKind::MismatchingDocblockParamType { .. }
866            | IssueKind::InvalidDocblock { .. }
867            | IssueKind::InvalidCast { .. }
868            | IssueKind::MixedArgument { .. }
869            | IssueKind::MixedAssignment { .. }
870            | IssueKind::MixedMethodCall { .. }
871            | IssueKind::NoInterfaceProperties { .. }
872            | IssueKind::UndefinedDocblockClass { .. }
873            | IssueKind::MissingConstructor { .. }
874            | IssueKind::MixedFunctionCall
875            | IssueKind::MixedReturnStatement { .. }
876            | IssueKind::MixedPropertyFetch { .. }
877            | IssueKind::MixedPropertyAssignment { .. }
878            | IssueKind::MixedArrayAccess
879            | IssueKind::MixedArrayOffset
880            | IssueKind::MixedClone
881            | IssueKind::ShadowedTemplateParam { .. }
882            | IssueKind::MissingThrowsDocblock { .. }
883            | IssueKind::WrongCaseFunction { .. }
884            | IssueKind::WrongCaseMethod { .. }
885            | IssueKind::WrongCaseClass { .. }
886            | IssueKind::InvalidAttribute { .. }
887            | IssueKind::UndefinedAttributeClass { .. } => Severity::Info,
888            IssueKind::DuplicateClass { .. }
889            | IssueKind::DuplicateInterface { .. }
890            | IssueKind::DuplicateTrait { .. }
891            | IssueKind::DuplicateEnum { .. }
892            | IssueKind::DuplicateFunction { .. } => Severity::Error,
893        }
894    }
895
896    /// Stable error code (e.g. `"MIR0005"`).
897    ///
898    /// Codes are assigned in bands by category and are part of the public API:
899    /// once a code ships, it must never be reused for a different issue kind.
900    /// New variants take the next free slot in their band; obsolete variants
901    /// retire their code (the slot stays burnt). Bands have headroom for growth.
902    ///
903    /// Bands:
904    ///
905    /// | Range         | Category                        |
906    /// |---------------|---------------------------------|
907    /// | 0001 – 0099   | Undefined symbols               |
908    /// | 0100 – 0199   | Nullability                     |
909    /// | 0200 – 0299   | Type mismatches                 |
910    /// | 0300 – 0399   | Array / offset                  |
911    /// | 0400 – 0499   | Redundancy                      |
912    /// | 0500 – 0599   | Dead code                       |
913    /// | 0600 – 0699   | Readonly                        |
914    /// | 0700 – 0799   | Inheritance                     |
915    /// | 0800 – 0899   | Security (taint)                |
916    /// | 0900 – 0999   | Generics                        |
917    /// | 1000 – 1099   | Deprecation / internal          |
918    /// | 1100 – 1199   | Missing types / docblocks       |
919    /// | 1200 – 1299   | Mixed                           |
920    /// | 1300 – 1399   | Trait                           |
921    /// | 1400 – 1499   | Parse                           |
922    /// | 1500 – 1599   | Other                           |
923    pub fn code(&self) -> &'static str {
924        match self {
925            // Undefined (0001-0099)
926            IssueKind::NonStaticSelfCall { .. } => "MIR0216",
927            IssueKind::DirectConstructorCall { .. } => "MIR0217",
928            IssueKind::InvalidScope { .. } => "MIR0001",
929            IssueKind::UndefinedVariable { .. } => "MIR0002",
930            IssueKind::UndefinedFunction { .. } => "MIR0003",
931            IssueKind::UndefinedMethod { .. } => "MIR0004",
932            IssueKind::UndefinedClass { .. } => "MIR0005",
933            IssueKind::UndefinedProperty { .. } => "MIR0006",
934            IssueKind::UndefinedConstant { .. } => "MIR0007",
935            IssueKind::InaccessibleClassConstant { .. } => "MIR0011",
936            IssueKind::PossiblyUndefinedVariable { .. } => "MIR0008",
937            IssueKind::UndefinedTrait { .. } => "MIR0009",
938            IssueKind::ParentNotFound => "MIR0010",
939
940            // Nullability (0100-0199)
941            IssueKind::NullArgument { .. } => "MIR0100",
942            IssueKind::NullPropertyFetch { .. } => "MIR0101",
943            IssueKind::NullMethodCall { .. } => "MIR0102",
944            IssueKind::NullArrayAccess => "MIR0103",
945            IssueKind::PossiblyNullArgument { .. } => "MIR0104",
946            IssueKind::PossiblyInvalidArgument { .. } => "MIR0105",
947            IssueKind::PossiblyNullPropertyFetch { .. } => "MIR0106",
948            IssueKind::PossiblyNullMethodCall { .. } => "MIR0107",
949            IssueKind::PossiblyNullArrayAccess => "MIR0108",
950            IssueKind::NullableReturnStatement { .. } => "MIR0109",
951
952            // Type mismatches (0200-0299)
953            IssueKind::InvalidReturnType { .. } => "MIR0200",
954            IssueKind::InvalidArgument { .. } => "MIR0201",
955            IssueKind::TooFewArguments { .. } => "MIR0202",
956            IssueKind::TooManyArguments { .. } => "MIR0203",
957            IssueKind::InvalidNamedArgument { .. } => "MIR0204",
958            IssueKind::InvalidNamedArguments { .. } => "MIR0224",
959            IssueKind::InvalidPassByReference { .. } => "MIR0205",
960            IssueKind::InvalidPropertyFetch { .. } => "MIR0218",
961            IssueKind::InvalidArrayAccess { .. } => "MIR0219",
962            IssueKind::PossiblyInvalidArrayAccess { .. } => "MIR0227",
963            IssueKind::InvalidArrayAssignment { .. } => "MIR0220",
964            IssueKind::InvalidPropertyAssignment { .. } => "MIR0206",
965            IssueKind::InvalidCast { .. } => "MIR0207",
966            IssueKind::InvalidStaticInvocation { .. } => "MIR0215",
967            IssueKind::InvalidOperand { .. } => "MIR0208",
968            IssueKind::PossiblyInvalidOperand { .. } => "MIR0213",
969            IssueKind::PossiblyNullOperand { .. } => "MIR0214",
970            IssueKind::DivisionByZero { .. } => "MIR0229",
971            IssueKind::RawObjectIteration { .. } => "MIR0222",
972            IssueKind::PossiblyRawObjectIteration { .. } => "MIR0223",
973            IssueKind::MismatchingDocblockReturnType { .. } => "MIR0209",
974            IssueKind::MismatchingDocblockParamType { .. } => "MIR0210",
975            IssueKind::InvalidStringClass { .. } => "MIR0211",
976            IssueKind::NotAnInterface { .. } => "MIR0228",
977            IssueKind::TypeCheckMismatch { .. } => "MIR0212",
978            IssueKind::Trace { .. } => "MIR0221",
979            IssueKind::ArgumentTypeCoercion { .. } => "MIR0225",
980            IssueKind::PropertyTypeCoercion { .. } => "MIR0226",
981
982            // Array / offset (0300-0399)
983            IssueKind::InvalidArrayOffset { .. } => "MIR0300",
984            IssueKind::NonExistentArrayOffset { .. } => "MIR0301",
985            IssueKind::PossiblyInvalidArrayOffset { .. } => "MIR0302",
986            IssueKind::DuplicateArrayKey { .. } => "MIR0303",
987
988            // Redundancy (0400-0499)
989            IssueKind::RedundantCondition { .. } => "MIR0400",
990            IssueKind::RedundantCast { .. } => "MIR0401",
991            IssueKind::UnnecessaryVarAnnotation { .. } => "MIR0402",
992            IssueKind::TypeDoesNotContainType { .. } => "MIR0403",
993            IssueKind::ParadoxicalCondition { .. } => "MIR0404",
994            IssueKind::UnhandledMatchCondition { .. } => "MIR0405",
995            IssueKind::DocblockTypeContradiction { .. } => "MIR0406",
996            IssueKind::UnevaluatedCode { .. } => "MIR0407",
997            IssueKind::ImpossibleIdenticalComparison { .. } => "MIR0408",
998            IssueKind::ImpossibleLooseComparison { .. } => "MIR0409",
999
1000            // Dead code (0500-0599)
1001            IssueKind::UnusedVariable { .. } => "MIR0500",
1002            IssueKind::UnusedParam { .. } => "MIR0501",
1003            IssueKind::UnreachableCode => "MIR0502",
1004            IssueKind::UnusedMethod { .. } => "MIR0503",
1005            IssueKind::UnusedProperty { .. } => "MIR0504",
1006            IssueKind::UnusedFunction { .. } => "MIR0505",
1007            IssueKind::UnusedForeachValue { .. } => "MIR0506",
1008            IssueKind::UnusedClass { .. } => "MIR0507",
1009            IssueKind::UnusedSuppress { .. } => "MIR0508",
1010
1011            // Purity (1700-1799)
1012            IssueKind::ImpurePropertyAssignment { .. } => "MIR1700",
1013            IssueKind::ImpureMethodCall { .. } => "MIR1701",
1014            IssueKind::ImpureGlobalVariable { .. } => "MIR1702",
1015            IssueKind::ImpureStaticVariable { .. } => "MIR1703",
1016            IssueKind::ImpureStaticPropertyAssignment { .. } => "MIR1706",
1017            IssueKind::ImpureFunctionCall { .. } => "MIR1704",
1018            IssueKind::ImmutablePropertyModification { .. } => "MIR1705",
1019            IssueKind::UnsupportedReferenceUsage => "MIR1506",
1020            IssueKind::NoInterfaceProperties { .. } => "MIR1504",
1021            IssueKind::UndefinedDocblockClass { .. } => "MIR1505",
1022            IssueKind::MissingConstructor { .. } => "MIR1507",
1023            IssueKind::MixedFunctionCall => "MIR1211",
1024            IssueKind::MixedReturnStatement { .. } => "MIR1212",
1025
1026            // Readonly (0600-0699)
1027            IssueKind::ReadonlyPropertyAssignment { .. } => "MIR0600",
1028
1029            // Inheritance (0700-0799)
1030            IssueKind::UnimplementedAbstractMethod { .. } => "MIR0700",
1031            IssueKind::UnimplementedInterfaceMethod { .. } => "MIR0701",
1032            IssueKind::MethodSignatureMismatch { .. } => "MIR0702",
1033            IssueKind::OverriddenMethodAccess { .. } => "MIR0703",
1034            IssueKind::OverriddenPropertyAccess { .. } => "MIR0710",
1035            IssueKind::PropertyTypeRedeclarationMismatch { .. } => "MIR0712",
1036            IssueKind::BackedEnumCaseTypeMismatch { .. } => "MIR0713",
1037            IssueKind::ReadonlyPropertyRedeclarationMismatch { .. } => "MIR0714",
1038            IssueKind::StaticPropertyRedeclarationMismatch { .. } => "MIR0715",
1039            IssueKind::InvalidExtendClass { .. } => "MIR0704",
1040            IssueKind::FinalMethodOverridden { .. } => "MIR0705",
1041            IssueKind::AbstractInstantiation { .. } => "MIR0706",
1042            IssueKind::AbstractMethodCall { .. } => "MIR0711",
1043            IssueKind::InterfaceInstantiation { .. } => "MIR0709",
1044            IssueKind::CircularInheritance { .. } => "MIR0707",
1045            IssueKind::InvalidOverride { .. } => "MIR0708",
1046
1047            // Security / taint (0800-0899)
1048            IssueKind::TaintedInput { .. } => "MIR0800",
1049            IssueKind::TaintedHtml => "MIR0801",
1050            IssueKind::TaintedSql => "MIR0802",
1051            IssueKind::TaintedShell => "MIR0803",
1052            IssueKind::TaintedLlmPrompt => "MIR0804",
1053
1054            // Generics (0900-0999)
1055            IssueKind::InvalidTemplateParam { .. } => "MIR0900",
1056            IssueKind::ShadowedTemplateParam { .. } => "MIR0901",
1057            IssueKind::IfThisIsMismatch { .. } => "MIR0902",
1058
1059            // Deprecation / internal (1000-1099)
1060            IssueKind::DeprecatedCall { .. } => "MIR1000",
1061            IssueKind::WrongCaseFunction { .. } => "MIR1009",
1062            IssueKind::WrongCaseMethod { .. } => "MIR1010",
1063            IssueKind::WrongCaseClass { .. } => "MIR1011",
1064            IssueKind::DeprecatedProperty { .. } => "MIR1005",
1065            IssueKind::DeprecatedInterface { .. } => "MIR1006",
1066            IssueKind::DeprecatedTrait { .. } => "MIR1007",
1067            IssueKind::DeprecatedConstant { .. } => "MIR1008",
1068            IssueKind::DeprecatedMethodCall { .. } => "MIR1001",
1069            IssueKind::DeprecatedMethod { .. } => "MIR1002",
1070            IssueKind::DeprecatedClass { .. } => "MIR1003",
1071            IssueKind::InternalMethod { .. } => "MIR1004",
1072
1073            // Missing types / docblocks (1100-1199)
1074            IssueKind::MissingReturnType { .. } => "MIR1100",
1075            IssueKind::MissingParamType { .. } => "MIR1101",
1076            IssueKind::MissingPropertyType { .. } => "MIR1104",
1077            IssueKind::MissingClosureReturnType => "MIR1105",
1078            IssueKind::MissingThrowsDocblock { .. } => "MIR1102",
1079            IssueKind::InvalidDocblock { .. } => "MIR1103",
1080
1081            // Mixed (1200-1299)
1082            IssueKind::MixedArgument { .. } => "MIR1200",
1083            IssueKind::MixedAssignment { .. } => "MIR1201",
1084            IssueKind::MixedMethodCall { .. } => "MIR1202",
1085            IssueKind::MixedPropertyFetch { .. } => "MIR1203",
1086            IssueKind::MixedPropertyAssignment { .. } => "MIR1208",
1087            IssueKind::MixedArrayAccess => "MIR1209",
1088            IssueKind::MixedArrayOffset => "MIR1210",
1089            IssueKind::MixedClone => "MIR1204",
1090            IssueKind::InvalidClone { .. } => "MIR1205",
1091            IssueKind::PossiblyInvalidClone { .. } => "MIR1206",
1092            IssueKind::InvalidToString { .. } => "MIR1207",
1093
1094            // Trait (1300-1399)
1095            IssueKind::InvalidTraitUse { .. } => "MIR1300",
1096            IssueKind::ForbiddenCode { .. } => "MIR1301",
1097
1098            // Parse (1400-1499)
1099            IssueKind::ParseError { .. } => "MIR1400",
1100
1101            // Attribute (1600-1699)
1102            IssueKind::InvalidAttribute { .. } => "MIR1600",
1103            IssueKind::UndefinedAttributeClass { .. } => "MIR1601",
1104            IssueKind::DuplicateClass { .. } => "MIR1602",
1105            IssueKind::DuplicateInterface { .. } => "MIR1603",
1106            IssueKind::DuplicateTrait { .. } => "MIR1604",
1107            IssueKind::DuplicateEnum { .. } => "MIR1605",
1108            IssueKind::DuplicateFunction { .. } => "MIR1606",
1109
1110            // Other (1500-1599)
1111            IssueKind::InvalidThrow { .. } => "MIR1500",
1112            IssueKind::InvalidCatch { .. } => "MIR1503",
1113            IssueKind::UnreachableCatch { .. } => "MIR1508",
1114            IssueKind::ImplicitToStringCast { .. } => "MIR1501",
1115            IssueKind::ImplicitFloatToIntCast { .. } => "MIR1502",
1116        }
1117    }
1118
1119    /// Returns the default [`Severity`] for a stable issue code (e.g. `"MIR0005"`).
1120    ///
1121    /// Useful when a caller holds a bare code string — from config, suppression
1122    /// annotations, or serialised diagnostics — and needs to recover the severity
1123    /// without constructing a full [`IssueKind`]. Returns `None` for unknown codes.
1124    pub fn default_severity_for_code(code: &str) -> Option<Severity> {
1125        match code {
1126            // Errors
1127            "MIR0001" | "MIR0002" | "MIR0003" | "MIR0004" | "MIR0005" | "MIR0007" | "MIR0009"
1128            | "MIR0010" | "MIR0011" | "MIR0200" | "MIR0201" | "MIR0202" | "MIR0203" | "MIR0204"
1129            | "MIR0205" | "MIR0212" | "MIR0215" | "MIR0216" | "MIR0217" | "MIR0224" | "MIR0600"
1130            | "MIR0700" | "MIR0701" | "MIR0702" | "MIR0704" | "MIR0705" | "MIR0706" | "MIR0707"
1131            | "MIR0708" | "MIR0709" | "MIR0711" | "MIR0712" | "MIR0713" | "MIR0714" | "MIR0715"
1132            | "MIR0228" | "MIR0229" | "MIR0800" | "MIR0801" | "MIR0802" | "MIR0803" | "MIR0804"
1133            | "MIR0900" | "MIR1205" | "MIR1207" | "MIR1300" | "MIR1400" | "MIR1500" | "MIR1503"
1134            | "MIR1602" | "MIR1603" | "MIR1604" | "MIR1605" | "MIR1606" => Some(Severity::Error),
1135
1136            // Warnings
1137            "MIR0006" | "MIR0008" | "MIR0100" | "MIR0101" | "MIR0102" | "MIR0103" | "MIR0109"
1138            | "MIR0206" | "MIR0208" | "MIR0211" | "MIR0218" | "MIR0219" | "MIR0220" | "MIR0222"
1139            | "MIR0300" | "MIR0301" | "MIR0302" | "MIR0303" | "MIR0404" | "MIR0405" | "MIR0408"
1140            | "MIR0500" | "MIR0506" | "MIR0703" | "MIR0710" | "MIR1301" | "MIR1501" | "MIR1502"
1141            | "MIR1700" | "MIR1701" | "MIR1702" | "MIR1703" | "MIR1704" | "MIR1705" | "MIR1706"
1142            | "MIR1506" => Some(Severity::Warning),
1143
1144            // Info
1145            "MIR0104" | "MIR0105" | "MIR0106" | "MIR0107" | "MIR0108" | "MIR0207" | "MIR0209"
1146            | "MIR0210" | "MIR0213" | "MIR0214" | "MIR0221" | "MIR0223" | "MIR0400" | "MIR0401"
1147            | "MIR0402" | "MIR0403" | "MIR0501" | "MIR0502" | "MIR0503" | "MIR0504" | "MIR0505"
1148            | "MIR0507" | "MIR0508" | "MIR0901" | "MIR1000" | "MIR1001" | "MIR1002" | "MIR1003"
1149            | "MIR1004" | "MIR1005" | "MIR1006" | "MIR1007" | "MIR1008" | "MIR1009" | "MIR1010"
1150            | "MIR1011" | "MIR1100" | "MIR1101" | "MIR1102" | "MIR1103" | "MIR1104" | "MIR1105"
1151            | "MIR1200" | "MIR1201" | "MIR1202" | "MIR1203" | "MIR1204" | "MIR1206" | "MIR1208"
1152            | "MIR1209" | "MIR1210" | "MIR1211" | "MIR1212" | "MIR1504" | "MIR1505" | "MIR1507"
1153            | "MIR1508" | "MIR1600" | "MIR1601" | "MIR0225" | "MIR0226" | "MIR0227" | "MIR0406"
1154            | "MIR0407" | "MIR0902" => Some(Severity::Info),
1155
1156            _ => None,
1157        }
1158    }
1159
1160    /// Identifier name used in config and `@psalm-suppress` / `@suppress` annotations.
1161    pub fn name(&self) -> &'static str {
1162        match self {
1163            IssueKind::NonStaticSelfCall { .. } => "NonStaticSelfCall",
1164            IssueKind::DirectConstructorCall { .. } => "DirectConstructorCall",
1165            IssueKind::InvalidScope { .. } => "InvalidScope",
1166            IssueKind::UndefinedVariable { .. } => "UndefinedVariable",
1167            IssueKind::UndefinedFunction { .. } => "UndefinedFunction",
1168            IssueKind::UndefinedMethod { .. } => "UndefinedMethod",
1169            IssueKind::UndefinedClass { .. } => "UndefinedClass",
1170            IssueKind::UndefinedProperty { .. } => "UndefinedProperty",
1171            IssueKind::UndefinedConstant { .. } => "UndefinedConstant",
1172            IssueKind::InaccessibleClassConstant { .. } => "InaccessibleClassConstant",
1173            IssueKind::PossiblyUndefinedVariable { .. } => "PossiblyUndefinedVariable",
1174            IssueKind::UndefinedTrait { .. } => "UndefinedTrait",
1175            IssueKind::ParentNotFound => "ParentNotFound",
1176            IssueKind::InvalidStringClass { .. } => "InvalidStringClass",
1177            IssueKind::NotAnInterface { .. } => "NotAnInterface",
1178            IssueKind::NullArgument { .. } => "NullArgument",
1179            IssueKind::NullPropertyFetch { .. } => "NullPropertyFetch",
1180            IssueKind::NullMethodCall { .. } => "NullMethodCall",
1181            IssueKind::NullArrayAccess => "NullArrayAccess",
1182            IssueKind::PossiblyNullArgument { .. } => "PossiblyNullArgument",
1183            IssueKind::PossiblyInvalidArgument { .. } => "PossiblyInvalidArgument",
1184            IssueKind::PossiblyNullPropertyFetch { .. } => "PossiblyNullPropertyFetch",
1185            IssueKind::PossiblyNullMethodCall { .. } => "PossiblyNullMethodCall",
1186            IssueKind::PossiblyNullArrayAccess => "PossiblyNullArrayAccess",
1187            IssueKind::NullableReturnStatement { .. } => "NullableReturnStatement",
1188            IssueKind::InvalidReturnType { .. } => "InvalidReturnType",
1189            IssueKind::InvalidArgument { .. } => "InvalidArgument",
1190            IssueKind::TooFewArguments { .. } => "TooFewArguments",
1191            IssueKind::TooManyArguments { .. } => "TooManyArguments",
1192            IssueKind::InvalidNamedArgument { .. } => "InvalidNamedArgument",
1193            IssueKind::InvalidNamedArguments { .. } => "InvalidNamedArguments",
1194            IssueKind::InvalidPassByReference { .. } => "InvalidPassByReference",
1195            IssueKind::InvalidPropertyFetch { .. } => "InvalidPropertyFetch",
1196            IssueKind::InvalidArrayAccess { .. } => "InvalidArrayAccess",
1197            IssueKind::PossiblyInvalidArrayAccess { .. } => "PossiblyInvalidArrayAccess",
1198            IssueKind::InvalidArrayAssignment { .. } => "InvalidArrayAssignment",
1199            IssueKind::InvalidPropertyAssignment { .. } => "InvalidPropertyAssignment",
1200            IssueKind::InvalidCast { .. } => "InvalidCast",
1201            IssueKind::InvalidStaticInvocation { .. } => "InvalidStaticInvocation",
1202            IssueKind::InvalidOperand { .. } => "InvalidOperand",
1203            IssueKind::PossiblyInvalidOperand { .. } => "PossiblyInvalidOperand",
1204            IssueKind::PossiblyNullOperand { .. } => "PossiblyNullOperand",
1205            IssueKind::DivisionByZero { .. } => "DivisionByZero",
1206            IssueKind::RawObjectIteration { .. } => "RawObjectIteration",
1207            IssueKind::PossiblyRawObjectIteration { .. } => "PossiblyRawObjectIteration",
1208            IssueKind::MismatchingDocblockReturnType { .. } => "MismatchingDocblockReturnType",
1209            IssueKind::MismatchingDocblockParamType { .. } => "MismatchingDocblockParamType",
1210            IssueKind::TypeCheckMismatch { .. } => "TypeCheckMismatch",
1211            IssueKind::DocblockTypeContradiction { .. } => "DocblockTypeContradiction",
1212            IssueKind::ImpossibleIdenticalComparison { .. } => "ImpossibleIdenticalComparison",
1213            IssueKind::ImpossibleLooseComparison { .. } => "ImpossibleLooseComparison",
1214            IssueKind::UnevaluatedCode { .. } => "UnevaluatedCode",
1215            IssueKind::IfThisIsMismatch { .. } => "IfThisIsMismatch",
1216            IssueKind::Trace { .. } => "Trace",
1217            IssueKind::InvalidArrayOffset { .. } => "InvalidArrayOffset",
1218            IssueKind::NonExistentArrayOffset { .. } => "NonExistentArrayOffset",
1219            IssueKind::PossiblyInvalidArrayOffset { .. } => "PossiblyInvalidArrayOffset",
1220            IssueKind::DuplicateArrayKey { .. } => "DuplicateArrayKey",
1221            IssueKind::RedundantCondition { .. } => "RedundantCondition",
1222            IssueKind::RedundantCast { .. } => "RedundantCast",
1223            IssueKind::UnnecessaryVarAnnotation { .. } => "UnnecessaryVarAnnotation",
1224            IssueKind::TypeDoesNotContainType { .. } => "TypeDoesNotContainType",
1225            IssueKind::ParadoxicalCondition { .. } => "ParadoxicalCondition",
1226            IssueKind::UnhandledMatchCondition { .. } => "UnhandledMatchCondition",
1227            IssueKind::UnusedVariable { .. } => "UnusedVariable",
1228            IssueKind::UnusedParam { .. } => "UnusedParam",
1229            IssueKind::UnreachableCode => "UnreachableCode",
1230            IssueKind::UnusedMethod { .. } => "UnusedMethod",
1231            IssueKind::UnusedProperty { .. } => "UnusedProperty",
1232            IssueKind::UnusedFunction { .. } => "UnusedFunction",
1233            IssueKind::UnusedForeachValue { .. } => "UnusedForeachValue",
1234            IssueKind::UnusedClass { .. } => "UnusedClass",
1235            IssueKind::UnusedSuppress { .. } => "UnusedSuppress",
1236            IssueKind::ArgumentTypeCoercion { .. } => "ArgumentTypeCoercion",
1237            IssueKind::PropertyTypeCoercion { .. } => "PropertyTypeCoercion",
1238            IssueKind::ImpurePropertyAssignment { .. } => "ImpurePropertyAssignment",
1239            IssueKind::ImpureMethodCall { .. } => "ImpureMethodCall",
1240            IssueKind::ImpureGlobalVariable { .. } => "ImpureGlobalVariable",
1241            IssueKind::ImpureStaticVariable { .. } => "ImpureStaticVariable",
1242            IssueKind::ImpureStaticPropertyAssignment { .. } => "ImpureStaticPropertyAssignment",
1243            IssueKind::ImpureFunctionCall { .. } => "ImpureFunctionCall",
1244            IssueKind::ImmutablePropertyModification { .. } => "ImmutablePropertyModification",
1245            IssueKind::UnsupportedReferenceUsage => "UnsupportedReferenceUsage",
1246            IssueKind::NoInterfaceProperties { .. } => "NoInterfaceProperties",
1247            IssueKind::UndefinedDocblockClass { .. } => "UndefinedDocblockClass",
1248            IssueKind::MissingConstructor { .. } => "MissingConstructor",
1249            IssueKind::MixedFunctionCall => "MixedFunctionCall",
1250            IssueKind::MixedReturnStatement { .. } => "MixedReturnStatement",
1251            IssueKind::UnimplementedAbstractMethod { .. } => "UnimplementedAbstractMethod",
1252            IssueKind::UnimplementedInterfaceMethod { .. } => "UnimplementedInterfaceMethod",
1253            IssueKind::MethodSignatureMismatch { .. } => "MethodSignatureMismatch",
1254            IssueKind::OverriddenMethodAccess { .. } => "OverriddenMethodAccess",
1255            IssueKind::OverriddenPropertyAccess { .. } => "OverriddenPropertyAccess",
1256            IssueKind::PropertyTypeRedeclarationMismatch { .. } => {
1257                "PropertyTypeRedeclarationMismatch"
1258            }
1259            IssueKind::ReadonlyPropertyRedeclarationMismatch { .. } => {
1260                "ReadonlyPropertyRedeclarationMismatch"
1261            }
1262            IssueKind::StaticPropertyRedeclarationMismatch { .. } => {
1263                "StaticPropertyRedeclarationMismatch"
1264            }
1265            IssueKind::BackedEnumCaseTypeMismatch { .. } => "BackedEnumCaseTypeMismatch",
1266            IssueKind::InvalidExtendClass { .. } => "InvalidExtendClass",
1267            IssueKind::FinalMethodOverridden { .. } => "FinalMethodOverridden",
1268            IssueKind::AbstractInstantiation { .. } => "AbstractInstantiation",
1269            IssueKind::AbstractMethodCall { .. } => "AbstractMethodCall",
1270            IssueKind::InterfaceInstantiation { .. } => "InterfaceInstantiation",
1271            IssueKind::InvalidOverride { .. } => "InvalidOverride",
1272            IssueKind::ReadonlyPropertyAssignment { .. } => "ReadonlyPropertyAssignment",
1273            IssueKind::InvalidTemplateParam { .. } => "InvalidTemplateParam",
1274            IssueKind::ShadowedTemplateParam { .. } => "ShadowedTemplateParam",
1275            IssueKind::TaintedInput { .. } => "TaintedInput",
1276            IssueKind::TaintedHtml => "TaintedHtml",
1277            IssueKind::TaintedSql => "TaintedSql",
1278            IssueKind::TaintedShell => "TaintedShell",
1279            IssueKind::TaintedLlmPrompt => "TaintedLlmPrompt",
1280            IssueKind::DeprecatedCall { .. } => "DeprecatedCall",
1281            IssueKind::DeprecatedProperty { .. } => "DeprecatedProperty",
1282            IssueKind::DeprecatedConstant { .. } => "DeprecatedConstant",
1283            IssueKind::DeprecatedInterface { .. } => "DeprecatedInterface",
1284            IssueKind::DeprecatedTrait { .. } => "DeprecatedTrait",
1285            IssueKind::DeprecatedMethodCall { .. } => "DeprecatedMethodCall",
1286            IssueKind::DeprecatedMethod { .. } => "DeprecatedMethod",
1287            IssueKind::DeprecatedClass { .. } => "DeprecatedClass",
1288            IssueKind::InternalMethod { .. } => "InternalMethod",
1289            IssueKind::MissingReturnType { .. } => "MissingReturnType",
1290            IssueKind::MissingClosureReturnType => "MissingClosureReturnType",
1291            IssueKind::MissingParamType { .. } => "MissingParamType",
1292            IssueKind::MissingPropertyType { .. } => "MissingPropertyType",
1293            IssueKind::InvalidThrow { .. } => "InvalidThrow",
1294            IssueKind::InvalidCatch { .. } => "InvalidCatch",
1295            IssueKind::UnreachableCatch { .. } => "UnreachableCatch",
1296            IssueKind::MissingThrowsDocblock { .. } => "MissingThrowsDocblock",
1297            IssueKind::ImplicitToStringCast { .. } => "ImplicitToStringCast",
1298            IssueKind::ImplicitFloatToIntCast { .. } => "ImplicitFloatToIntCast",
1299            IssueKind::ParseError { .. } => "ParseError",
1300            IssueKind::InvalidDocblock { .. } => "InvalidDocblock",
1301            IssueKind::MixedArgument { .. } => "MixedArgument",
1302            IssueKind::MixedAssignment { .. } => "MixedAssignment",
1303            IssueKind::MixedMethodCall { .. } => "MixedMethodCall",
1304            IssueKind::MixedPropertyFetch { .. } => "MixedPropertyFetch",
1305            IssueKind::MixedPropertyAssignment { .. } => "MixedPropertyAssignment",
1306            IssueKind::MixedArrayAccess => "MixedArrayAccess",
1307            IssueKind::MixedArrayOffset => "MixedArrayOffset",
1308            IssueKind::MixedClone => "MixedClone",
1309            IssueKind::InvalidClone { .. } => "InvalidClone",
1310            IssueKind::PossiblyInvalidClone { .. } => "PossiblyInvalidClone",
1311            IssueKind::InvalidToString { .. } => "InvalidToString",
1312            IssueKind::CircularInheritance { .. } => "CircularInheritance",
1313            IssueKind::InvalidTraitUse { .. } => "InvalidTraitUse",
1314            IssueKind::ForbiddenCode { .. } => "ForbiddenCode",
1315            IssueKind::WrongCaseFunction { .. } => "WrongCaseFunction",
1316            IssueKind::WrongCaseMethod { .. } => "WrongCaseMethod",
1317            IssueKind::WrongCaseClass { .. } => "WrongCaseClass",
1318            IssueKind::InvalidAttribute { .. } => "InvalidAttribute",
1319            IssueKind::UndefinedAttributeClass { .. } => "UndefinedAttributeClass",
1320            IssueKind::DuplicateClass { .. } => "DuplicateClass",
1321            IssueKind::DuplicateInterface { .. } => "DuplicateInterface",
1322            IssueKind::DuplicateTrait { .. } => "DuplicateTrait",
1323            IssueKind::DuplicateEnum { .. } => "DuplicateEnum",
1324            IssueKind::DuplicateFunction { .. } => "DuplicateFunction",
1325        }
1326    }
1327
1328    /// Human-readable message for this issue.
1329    pub fn message(&self) -> String {
1330        match self {
1331            IssueKind::NonStaticSelfCall { class, method } => {
1332                format!("Non-static method {class}::{method}() cannot be called statically")
1333            }
1334            IssueKind::DirectConstructorCall { class } => {
1335                format!("Cannot call constructor of {class} directly")
1336            }
1337            IssueKind::InvalidScope { in_class } => {
1338                if *in_class {
1339                    "$this cannot be used in a static method".to_string()
1340                } else {
1341                    "$this cannot be used outside of a class".to_string()
1342                }
1343            }
1344            IssueKind::UndefinedVariable { name } => format!("Variable ${name} is not defined"),
1345            IssueKind::UndefinedFunction { name } => format!("Function {name}() is not defined"),
1346            IssueKind::UndefinedMethod { class, method } => {
1347                format!("Method {class}::{method}() does not exist")
1348            }
1349            IssueKind::UndefinedClass { name } => format!("Class {name} does not exist"),
1350            IssueKind::UndefinedProperty { class, property } => {
1351                format!("Property {class}::${property} does not exist")
1352            }
1353            IssueKind::UndefinedConstant { name } => format!("Constant {name} is not defined"),
1354            IssueKind::InaccessibleClassConstant { class, constant } => {
1355                format!("Cannot access constant {class}::{constant}")
1356            }
1357            IssueKind::PossiblyUndefinedVariable { name } => {
1358                format!("Variable ${name} might not be defined")
1359            }
1360            IssueKind::UndefinedTrait { name } => format!("Trait {name} does not exist"),
1361            IssueKind::ParentNotFound => {
1362                "Cannot use parent:: when current class has no parent".to_string()
1363            }
1364            IssueKind::InvalidStringClass { actual } => {
1365                format!("Dynamic class instantiation requires string or class-string type, got '{actual}'")
1366            }
1367            IssueKind::NotAnInterface { name } => {
1368                format!("{name} is not an interface")
1369            }
1370
1371            IssueKind::NullArgument { param, fn_name } => {
1372                format!("Argument ${param} of {fn_name}() cannot be null")
1373            }
1374            IssueKind::NullPropertyFetch { property } => {
1375                format!("Cannot access property ${property} on null")
1376            }
1377            IssueKind::NullMethodCall { method } => {
1378                format!("Cannot call method {method}() on null")
1379            }
1380            IssueKind::NullArrayAccess => "Cannot access array on null".to_string(),
1381            IssueKind::PossiblyNullArgument { param, fn_name } => {
1382                format!("Argument ${param} of {fn_name}() might be null")
1383            }
1384            IssueKind::PossiblyInvalidArgument {
1385                param,
1386                fn_name,
1387                expected,
1388                actual,
1389            } => {
1390                format!("Argument ${param} of {fn_name}() expects '{expected}', possibly different type '{actual}' provided")
1391            }
1392            IssueKind::PossiblyNullPropertyFetch { property } => {
1393                format!("Cannot access property ${property} on possibly null value")
1394            }
1395            IssueKind::PossiblyNullMethodCall { method } => {
1396                format!("Cannot call method {method}() on possibly null value")
1397            }
1398            IssueKind::PossiblyNullArrayAccess => {
1399                "Cannot access array on possibly null value".to_string()
1400            }
1401            IssueKind::NullableReturnStatement { expected, actual } => {
1402                format!("Return type '{actual}' is not compatible with declared '{expected}'")
1403            }
1404
1405            IssueKind::InvalidReturnType { expected, actual } => {
1406                format!("Return type '{actual}' is not compatible with declared '{expected}'")
1407            }
1408            IssueKind::InvalidArgument {
1409                param,
1410                fn_name,
1411                expected,
1412                actual,
1413            } => {
1414                format!("Argument ${param} of {fn_name}() expects '{expected}', got '{actual}'")
1415            }
1416            IssueKind::TooFewArguments {
1417                fn_name,
1418                expected,
1419                actual,
1420            } => {
1421                format!(
1422                    "Too few arguments for {}(): expected {}, got {}",
1423                    fn_name, expected, actual
1424                )
1425            }
1426            IssueKind::TooManyArguments {
1427                fn_name,
1428                expected,
1429                actual,
1430            } => {
1431                format!(
1432                    "Too many arguments for {}(): expected {}, got {}",
1433                    fn_name, expected, actual
1434                )
1435            }
1436            IssueKind::InvalidNamedArgument { fn_name, name } => {
1437                format!("{}() has no parameter named ${}", fn_name, name)
1438            }
1439            IssueKind::InvalidNamedArguments { fn_name } => {
1440                format!("{}() does not accept named arguments", fn_name)
1441            }
1442            IssueKind::InvalidPassByReference { fn_name, param } => {
1443                format!(
1444                    "Argument ${} of {}() must be passed by reference",
1445                    param, fn_name
1446                )
1447            }
1448            IssueKind::InvalidPropertyFetch { ty } => {
1449                format!("Cannot fetch property on non-object type '{ty}'")
1450            }
1451            IssueKind::InvalidArrayAccess { ty } => {
1452                format!("Cannot use [] operator on non-array type '{ty}'")
1453            }
1454            IssueKind::PossiblyInvalidArrayAccess { ty } => {
1455                format!("Possibly invalid array access: '{ty}' might not support []")
1456            }
1457            IssueKind::InvalidArrayAssignment { ty } => {
1458                format!("Cannot use [] assignment on non-array type '{ty}'")
1459            }
1460            IssueKind::InvalidPropertyAssignment {
1461                property,
1462                expected,
1463                actual,
1464            } => {
1465                format!("Property ${property} expects '{expected}', cannot assign '{actual}'")
1466            }
1467            IssueKind::InvalidCast { from, to } => {
1468                format!("Cannot cast '{from}' to '{to}'")
1469            }
1470            IssueKind::InvalidStaticInvocation { class, method } => {
1471                format!("Non-static method {class}::{method}() cannot be called statically")
1472            }
1473            IssueKind::InvalidOperand { op, left, right } => {
1474                format!("Operator '{op}' not supported between '{left}' and '{right}'")
1475            }
1476            IssueKind::PossiblyInvalidOperand { op, left, right } => {
1477                format!("Operator '{op}' might not be supported between '{left}' and '{right}'")
1478            }
1479            IssueKind::PossiblyNullOperand { op, ty } => {
1480                format!("Operator '{op}' operand '{ty}' might be null")
1481            }
1482            IssueKind::DivisionByZero { op } => {
1483                format!("Division by zero: right operand of '{op}' is always 0")
1484            }
1485            IssueKind::RawObjectIteration { ty } => {
1486                format!("Cannot iterate over non-iterable object '{ty}'")
1487            }
1488            IssueKind::PossiblyRawObjectIteration { ty } => {
1489                format!("Cannot iterate over possibly non-iterable object '{ty}'")
1490            }
1491            IssueKind::MismatchingDocblockReturnType { declared, inferred } => {
1492                format!("Docblock return type '{declared}' does not match inferred '{inferred}'")
1493            }
1494            IssueKind::MismatchingDocblockParamType {
1495                param,
1496                declared,
1497                inferred,
1498            } => {
1499                format!(
1500                    "Docblock type '{declared}' for ${param} does not match inferred '{inferred}'"
1501                )
1502            }
1503            IssueKind::TypeCheckMismatch {
1504                var,
1505                expected,
1506                actual,
1507            } => {
1508                format!("Type of ${var} is expected to be {expected}, got {actual}")
1509            }
1510            IssueKind::Trace {
1511                variable,
1512                type_info,
1513            } => {
1514                format!("Type of ${variable} is {type_info}")
1515            }
1516
1517            IssueKind::InvalidArrayOffset { expected, actual } => {
1518                format!("Array offset expects '{expected}', got '{actual}'")
1519            }
1520            IssueKind::NonExistentArrayOffset { key } => {
1521                format!("Array offset '{key}' does not exist")
1522            }
1523            IssueKind::PossiblyInvalidArrayOffset { expected, actual } => {
1524                format!("Array offset might be invalid: expects '{expected}', got '{actual}'")
1525            }
1526            IssueKind::DuplicateArrayKey { key } => {
1527                format!("Array key {key} is duplicated — the earlier entry is silently overwritten")
1528            }
1529
1530            IssueKind::RedundantCondition { ty } => {
1531                format!("Condition is always true/false for type '{ty}'")
1532            }
1533            IssueKind::RedundantCast { from, to } => {
1534                format!("Casting '{from}' to '{to}' is redundant")
1535            }
1536            IssueKind::UnnecessaryVarAnnotation { var } => {
1537                format!("@var annotation for ${var} is unnecessary")
1538            }
1539            IssueKind::TypeDoesNotContainType { left, right } => {
1540                format!("Type '{left}' can never contain type '{right}'")
1541            }
1542            IssueKind::ParadoxicalCondition { value } => {
1543                format!("Value {value} is duplicated; this branch can never be reached")
1544            }
1545            IssueKind::UnhandledMatchCondition { detail } => {
1546                format!("Unhandled match condition: {detail}")
1547            }
1548            IssueKind::DocblockTypeContradiction { expr, declared } => {
1549                format!("Type '{declared}' makes '{expr}' impossible — this can never hold")
1550            }
1551            IssueKind::ImpossibleIdenticalComparison { op, left, right } => {
1552                let result = if op == "===" { "false" } else { "true" };
1553                format!("'{op}' between '{left}' and '{right}' is always {result} — these types can never be identical")
1554            }
1555            IssueKind::ImpossibleLooseComparison { op, left, right } => {
1556                let result = if op == "==" { "false" } else { "true" };
1557                format!("'{op}' between '{left}' and '{right}' is always {result} — these types can never be loosely equal")
1558            }
1559            IssueKind::UnevaluatedCode { reason } => {
1560                format!("Unevaluated code: {reason}")
1561            }
1562            IssueKind::IfThisIsMismatch {
1563                class,
1564                method,
1565                expected,
1566                actual,
1567            } => {
1568                format!(
1569                    "Cannot call {class}::{method}() — @if-this-is requires $this to be '{expected}', but it is '{actual}'"
1570                )
1571            }
1572
1573            IssueKind::UnusedVariable { name } => format!("Variable ${name} is never read"),
1574            IssueKind::UnusedParam { name } => format!("Parameter ${name} is never used"),
1575            IssueKind::UnreachableCode => "Unreachable code detected".to_string(),
1576            IssueKind::UnusedMethod { class, method } => {
1577                format!("Private method {class}::{method}() is never called")
1578            }
1579            IssueKind::UnusedProperty { class, property } => {
1580                format!("Private property {class}::${property} is never read")
1581            }
1582            IssueKind::UnusedFunction { name } => {
1583                format!("Function {name}() is never called")
1584            }
1585            IssueKind::UnusedForeachValue { name } => {
1586                format!("Foreach value ${name} is never read")
1587            }
1588            IssueKind::UnusedClass { class } => {
1589                format!("Class {class} is never referenced")
1590            }
1591            IssueKind::UnusedSuppress { kind } => {
1592                format!("Suppress annotation for '{kind}' is never used")
1593            }
1594            IssueKind::ArgumentTypeCoercion {
1595                param,
1596                fn_name,
1597                expected,
1598                actual,
1599            } => {
1600                format!("Argument ${param} of {fn_name}() expects '{expected}', got '{actual}' — coercion may fail at runtime")
1601            }
1602            IssueKind::PropertyTypeCoercion {
1603                property,
1604                expected,
1605                actual,
1606            } => {
1607                format!("Property ${property} expects '{expected}', cannot assign '{actual}' — coercion may fail at runtime")
1608            }
1609            IssueKind::ImpurePropertyAssignment { property } => {
1610                format!("Assigning to property {property} of a parameter in a pure or external-mutation-free context")
1611            }
1612            IssueKind::ImpureMethodCall { method } => {
1613                format!("Calling impure method {method}() in a pure or immutable context")
1614            }
1615            IssueKind::ImpureGlobalVariable { variable } => {
1616                format!("Using global variable ${variable} in a @pure function")
1617            }
1618            IssueKind::ImpureStaticVariable { variable } => {
1619                format!("Using static variable ${variable} in a @pure function")
1620            }
1621            IssueKind::ImpureStaticPropertyAssignment { class, property } => {
1622                format!("Assigning to static property {class}::${property} in a @pure function")
1623            }
1624            IssueKind::ImpureFunctionCall { fn_name } => {
1625                format!("Calling impure function {fn_name}() in a @pure function")
1626            }
1627            IssueKind::ImmutablePropertyModification { property } => {
1628                format!("Assigning to property {property} of $this in an immutable context (@psalm-immutable class or @psalm-mutation-free method)")
1629            }
1630
1631            IssueKind::UnimplementedAbstractMethod { class, method } => {
1632                format!("Class {class} must implement abstract method {method}()")
1633            }
1634            IssueKind::UnimplementedInterfaceMethod {
1635                class,
1636                interface,
1637                method,
1638            } => {
1639                format!("Class {class} must implement {interface}::{method}() from interface")
1640            }
1641            IssueKind::MethodSignatureMismatch {
1642                class,
1643                method,
1644                detail,
1645            } => {
1646                format!("Method {class}::{method}() signature mismatch: {detail}")
1647            }
1648            IssueKind::OverriddenMethodAccess { class, method } => {
1649                format!("Method {class}::{method}() overrides with less visibility")
1650            }
1651            IssueKind::OverriddenPropertyAccess { class, property } => {
1652                format!("Property {class}::${property} overrides with less visibility")
1653            }
1654            IssueKind::PropertyTypeRedeclarationMismatch {
1655                class,
1656                property,
1657                expected,
1658                actual,
1659            } => {
1660                format!(
1661                    "Type of {class}::${property} must be {expected} (as in parent class), {actual} given"
1662                )
1663            }
1664            IssueKind::ReadonlyPropertyRedeclarationMismatch {
1665                parent_class,
1666                class,
1667                property,
1668                parent_readonly,
1669            } => {
1670                if *parent_readonly {
1671                    format!(
1672                        "Cannot redeclare readonly property {parent_class}::${property} as non-readonly {class}::${property}"
1673                    )
1674                } else {
1675                    format!(
1676                        "Cannot redeclare non-readonly property {parent_class}::${property} as readonly {class}::${property}"
1677                    )
1678                }
1679            }
1680            IssueKind::StaticPropertyRedeclarationMismatch {
1681                parent_class,
1682                class,
1683                property,
1684                parent_static,
1685            } => {
1686                if *parent_static {
1687                    format!(
1688                        "Cannot redeclare static property {parent_class}::${property} as non-static {class}::${property}"
1689                    )
1690                } else {
1691                    format!(
1692                        "Cannot redeclare non-static property {parent_class}::${property} as static {class}::${property}"
1693                    )
1694                }
1695            }
1696            IssueKind::BackedEnumCaseTypeMismatch {
1697                enum_name,
1698                case_name,
1699                expected,
1700                actual,
1701            } => {
1702                format!(
1703                    "Backed enum case {enum_name}::{case_name} has value of type {actual}, but backing type is {expected}"
1704                )
1705            }
1706            IssueKind::ReadonlyPropertyAssignment { class, property } => {
1707                format!(
1708                    "Cannot assign to readonly property {class}::${property} outside of constructor"
1709                )
1710            }
1711            IssueKind::InvalidExtendClass { parent, child } => {
1712                format!("Class {child} cannot extend final class {parent}")
1713            }
1714            IssueKind::InvalidTemplateParam {
1715                name,
1716                expected_bound,
1717                actual,
1718            } => {
1719                format!(
1720                    "Template type '{name}' inferred as '{actual}' does not satisfy bound '{expected_bound}'"
1721                )
1722            }
1723            IssueKind::ShadowedTemplateParam { name } => {
1724                format!(
1725                    "Method template parameter '{name}' shadows class-level template parameter with the same name"
1726                )
1727            }
1728            IssueKind::FinalMethodOverridden {
1729                class,
1730                method,
1731                parent,
1732            } => {
1733                format!("Method {class}::{method}() cannot override final method from {parent}")
1734            }
1735            IssueKind::AbstractInstantiation { class } => {
1736                format!("Cannot instantiate abstract class {class}")
1737            }
1738            IssueKind::AbstractMethodCall { class, method } => {
1739                format!("Cannot call abstract method {class}::{method}()")
1740            }
1741            IssueKind::InterfaceInstantiation { class } => {
1742                format!("Cannot instantiate interface {class}")
1743            }
1744            IssueKind::InvalidOverride {
1745                class,
1746                method,
1747                detail,
1748            } => {
1749                format!("Method {class}::{method}() has #[Override] but {detail}")
1750            }
1751
1752            IssueKind::TaintedInput { sink } => format!("Tainted input reaching sink '{sink}'"),
1753            IssueKind::TaintedHtml => "Tainted HTML output — possible XSS".to_string(),
1754            IssueKind::TaintedSql => "Tainted SQL query — possible SQL injection".to_string(),
1755            IssueKind::TaintedShell => {
1756                "Tainted shell command — possible command injection".to_string()
1757            }
1758            IssueKind::TaintedLlmPrompt => {
1759                "Tainted LLM prompt — possible prompt injection".to_string()
1760            }
1761
1762            IssueKind::DeprecatedCall { name, message } => {
1763                let base = format!("Call to deprecated function {name}");
1764                append_deprecation_message(base, message)
1765            }
1766            IssueKind::DeprecatedProperty {
1767                class,
1768                property,
1769                message,
1770            } => {
1771                let base = format!("Property {class}::${property} is deprecated");
1772                append_deprecation_message(base, message)
1773            }
1774            IssueKind::DeprecatedConstant {
1775                class,
1776                constant,
1777                message,
1778            } => {
1779                let base = format!("Constant {class}::{constant} is deprecated");
1780                append_deprecation_message(base, message)
1781            }
1782            IssueKind::DeprecatedInterface { name, message } => {
1783                let base = format!("Interface {name} is deprecated");
1784                append_deprecation_message(base, message)
1785            }
1786            IssueKind::DeprecatedTrait { name, message } => {
1787                let base = format!("Trait {name} is deprecated");
1788                append_deprecation_message(base, message)
1789            }
1790            IssueKind::DeprecatedMethodCall {
1791                class,
1792                method,
1793                message,
1794            } => {
1795                let base = format!("Call to deprecated method {class}::{method}");
1796                append_deprecation_message(base, message)
1797            }
1798            IssueKind::DeprecatedMethod {
1799                class,
1800                method,
1801                message,
1802            } => {
1803                let base = format!("Method {class}::{method}() is deprecated");
1804                append_deprecation_message(base, message)
1805            }
1806            IssueKind::DeprecatedClass { name, message } => {
1807                let base = format!("Class {name} is deprecated");
1808                append_deprecation_message(base, message)
1809            }
1810            IssueKind::InternalMethod { class, method } => {
1811                format!("Method {class}::{method}() is marked @internal")
1812            }
1813            IssueKind::MissingReturnType { fn_name } => {
1814                format!("Function {fn_name}() has no return type annotation")
1815            }
1816            IssueKind::MissingClosureReturnType => {
1817                "Closure has no return type annotation".to_string()
1818            }
1819            IssueKind::MissingParamType { fn_name, param } => {
1820                format!("Parameter ${param} of {fn_name}() has no type annotation")
1821            }
1822            IssueKind::MissingPropertyType { class, property } => {
1823                format!("Property {class}::${property} has no type annotation")
1824            }
1825            IssueKind::InvalidThrow { ty } => {
1826                format!("Thrown type '{ty}' does not extend Throwable")
1827            }
1828            IssueKind::InvalidCatch { ty } => {
1829                format!("Caught type '{ty}' does not extend Throwable")
1830            }
1831            IssueKind::UnreachableCatch { ty, shadowed_by } => {
1832                format!("Catch block for '{ty}' is unreachable — already caught by '{shadowed_by}'")
1833            }
1834            IssueKind::MissingThrowsDocblock { class } => {
1835                format!("Exception {class} is thrown but not declared in @throws")
1836            }
1837            IssueKind::ImplicitToStringCast { class } => {
1838                format!("Class {class} is implicitly cast to string")
1839            }
1840            IssueKind::ImplicitFloatToIntCast { from } => {
1841                format!("Implicit cast from {from} to int truncates the fractional part")
1842            }
1843            IssueKind::ParseError { message } => format!("Parse error: {message}"),
1844            IssueKind::InvalidDocblock { message } => format!("Invalid docblock: {message}"),
1845            IssueKind::MixedArgument { param, fn_name } => {
1846                format!("Argument ${param} of {fn_name}() is mixed")
1847            }
1848            IssueKind::MixedAssignment { var } => {
1849                format!("Variable ${var} is assigned a mixed type")
1850            }
1851            IssueKind::MixedMethodCall { method } => {
1852                format!("Method {method}() called on mixed type")
1853            }
1854            IssueKind::UnsupportedReferenceUsage => {
1855                "Reference assignment is not supported".to_string()
1856            }
1857            IssueKind::NoInterfaceProperties { property } => {
1858                format!("Property ${property} is not defined on sealed interface")
1859            }
1860            IssueKind::UndefinedDocblockClass { name } => {
1861                format!("Docblock type '{name}' does not exist")
1862            }
1863            IssueKind::MissingConstructor { class } => {
1864                format!("Class {class} has uninitialized properties but no constructor")
1865            }
1866            IssueKind::MixedFunctionCall => "Cannot call mixed type as a function".to_string(),
1867            IssueKind::MixedReturnStatement { declared } => {
1868                format!("Cannot return a mixed type from function with declared return type '{declared}'")
1869            }
1870            IssueKind::MixedPropertyFetch { property } => {
1871                format!("Property ${property} fetched on mixed type")
1872            }
1873            IssueKind::MixedPropertyAssignment { property } => {
1874                format!("Property ${property} assigned on mixed type")
1875            }
1876            IssueKind::MixedArrayAccess => "Array access on mixed type".to_string(),
1877            IssueKind::MixedArrayOffset => "Mixed type used as array offset".to_string(),
1878            IssueKind::MixedClone => "cannot clone mixed".to_string(),
1879            IssueKind::InvalidClone { ty } => format!("cannot clone non-object {ty}"),
1880            IssueKind::PossiblyInvalidClone { ty } => {
1881                format!("cannot clone possibly non-object {ty}")
1882            }
1883            IssueKind::InvalidToString { class } => {
1884                format!("Method {class}::__toString() must return a string")
1885            }
1886            IssueKind::CircularInheritance { class } => {
1887                format!("Class {class} has a circular inheritance chain")
1888            }
1889            IssueKind::InvalidTraitUse { trait_name, reason } => {
1890                format!("Trait {trait_name} used incorrectly: {reason}")
1891            }
1892            IssueKind::WrongCaseFunction { used, canonical } => {
1893                format!("Function name '{used}' has incorrect casing; use '{canonical}'")
1894            }
1895            IssueKind::WrongCaseMethod {
1896                class,
1897                used,
1898                canonical,
1899            } => {
1900                format!("Method name '{class}::{used}' has incorrect casing; use '{canonical}'")
1901            }
1902            IssueKind::WrongCaseClass { used, canonical } => {
1903                format!("Class name '{used}' has incorrect casing; use '{canonical}'")
1904            }
1905            IssueKind::InvalidAttribute { message } => message.clone(),
1906            IssueKind::UndefinedAttributeClass { name } => {
1907                format!("Attribute class {name} does not exist")
1908            }
1909            IssueKind::ForbiddenCode { message } => message.clone(),
1910            IssueKind::DuplicateClass { name } => {
1911                format!("Class {name} has already been defined")
1912            }
1913            IssueKind::DuplicateInterface { name } => {
1914                format!("Interface {name} has already been defined")
1915            }
1916            IssueKind::DuplicateTrait { name } => {
1917                format!("Trait {name} has already been defined")
1918            }
1919            IssueKind::DuplicateEnum { name } => {
1920                format!("Enum {name} has already been defined")
1921            }
1922            IssueKind::DuplicateFunction { name } => {
1923                format!("Function {name}() has already been defined")
1924            }
1925        }
1926    }
1927}
1928
1929// ---------------------------------------------------------------------------
1930// Issue
1931// ---------------------------------------------------------------------------
1932
1933#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1934pub struct Issue {
1935    pub kind: IssueKind,
1936    pub severity: Severity,
1937    pub location: Location,
1938    pub snippet: Option<String>,
1939    pub suppressed: bool,
1940}
1941
1942impl Issue {
1943    pub fn new(kind: IssueKind, location: Location) -> Self {
1944        let severity = kind.default_severity();
1945        Self {
1946            severity,
1947            kind,
1948            location,
1949            snippet: None,
1950            suppressed: false,
1951        }
1952    }
1953
1954    pub fn with_snippet(mut self, snippet: impl Into<String>) -> Self {
1955        self.snippet = Some(snippet.into());
1956        self
1957    }
1958
1959    pub fn suppress(mut self) -> Self {
1960        self.suppressed = true;
1961        self
1962    }
1963}
1964
1965impl fmt::Display for Issue {
1966    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1967        let sev = match self.severity {
1968            Severity::Error => "error".red().to_string(),
1969            Severity::Warning => "warning".yellow().to_string(),
1970            Severity::Info => "info".blue().to_string(),
1971        };
1972        write!(
1973            f,
1974            "{} {}[{}] {}: {}",
1975            self.location.bright_black(),
1976            sev,
1977            self.kind.code().bright_black(),
1978            self.kind.name().bold(),
1979            self.kind.message()
1980        )
1981    }
1982}
1983
1984// ---------------------------------------------------------------------------
1985// IssueBuffer — collects issues for a single file pass
1986// ---------------------------------------------------------------------------
1987
1988#[derive(Debug, Default)]
1989pub struct IssueBuffer {
1990    issues: Vec<Issue>,
1991    seen: HashSet<(&'static str, Arc<str>, u32, u16)>,
1992    /// Issue names suppressed at the file level (from `@psalm-suppress` / `@suppress` on the file docblock)
1993    file_suppressions: Vec<String>,
1994}
1995
1996impl IssueBuffer {
1997    pub fn new() -> Self {
1998        Self::default()
1999    }
2000
2001    pub fn add(&mut self, issue: Issue) {
2002        let key = (
2003            issue.kind.name(),
2004            issue.location.file.clone(),
2005            issue.location.line,
2006            issue.location.col_start,
2007        );
2008        if self.seen.insert(key) {
2009            self.issues.push(issue);
2010        }
2011    }
2012
2013    pub fn add_suppression(&mut self, name: impl Into<String>) {
2014        self.file_suppressions.push(name.into());
2015    }
2016
2017    /// Consume the buffer and return unsuppressed issues.
2018    pub fn into_issues(self) -> Vec<Issue> {
2019        self.issues
2020            .into_iter()
2021            .filter(|i| !i.suppressed)
2022            .filter(|i| !self.file_suppressions.contains(&i.kind.name().to_string()))
2023            .collect()
2024    }
2025
2026    /// Like `into_issues` but keeps suppressed issues (with `suppressed = true`)
2027    /// so callers that need to detect unused suppressions can see which issues
2028    /// were silenced. File-level suppressions are also marked `suppressed = true`
2029    /// rather than dropped.
2030    pub fn into_all_issues(self) -> Vec<Issue> {
2031        self.issues
2032            .into_iter()
2033            .map(|mut i| {
2034                if self.file_suppressions.contains(&i.kind.name().to_string()) {
2035                    i.suppressed = true;
2036                }
2037                i
2038            })
2039            .collect()
2040    }
2041
2042    /// Mark all issues added since index `from` as suppressed if their issue
2043    /// name appears in `suppressions`. Used for `@psalm-suppress` / `@suppress` on statements.
2044    pub fn suppress_range(&mut self, from: usize, suppressions: &[String]) {
2045        if suppressions.is_empty() {
2046            return;
2047        }
2048        for issue in self.issues[from..].iter_mut() {
2049            if suppressions.iter().any(|s| s == issue.kind.name()) {
2050                issue.suppressed = true;
2051            }
2052        }
2053    }
2054
2055    /// Current number of buffered issues. Use before analyzing a statement to
2056    /// get the `from` index for `suppress_range`.
2057    pub fn issue_count(&self) -> usize {
2058        self.issues.len()
2059    }
2060
2061    /// Discard every issue added since index `mark`, un-marking their
2062    /// locations as `seen` so a later `add()` at the same spot isn't silently
2063    /// deduplicated away. Used to roll back a speculative analysis pass (e.g.
2064    /// an unstabilized loop fixed-point iteration) whose diagnostics were
2065    /// provisional and must not leak into the final result.
2066    pub fn truncate_to(&mut self, mark: usize) {
2067        for issue in self.issues.drain(mark..) {
2068            let key = (
2069                issue.kind.name(),
2070                issue.location.file.clone(),
2071                issue.location.line,
2072                issue.location.col_start,
2073            );
2074            self.seen.remove(&key);
2075        }
2076    }
2077
2078    pub fn is_empty(&self) -> bool {
2079        self.issues.is_empty()
2080    }
2081
2082    pub fn len(&self) -> usize {
2083        self.issues.len()
2084    }
2085
2086    pub fn error_count(&self) -> usize {
2087        self.issues
2088            .iter()
2089            .filter(|i| !i.suppressed && i.severity == Severity::Error)
2090            .count()
2091    }
2092
2093    pub fn warning_count(&self) -> usize {
2094        self.issues
2095            .iter()
2096            .filter(|i| !i.suppressed && i.severity == Severity::Warning)
2097            .count()
2098    }
2099}
2100
2101#[cfg(test)]
2102mod code_tests {
2103    use super::*;
2104    use std::collections::HashSet;
2105
2106    /// Returns one instance of every `IssueKind` variant.
2107    ///
2108    /// Updating `IssueKind` without updating this list will compile (it's a
2109    /// regular `Vec`), but `codes_cover_every_variant` will catch the omission
2110    /// — the test below asserts the count matches the exhaustive `code()` arm.
2111    fn one_of_each() -> Vec<IssueKind> {
2112        let s = || String::new();
2113        vec![
2114            IssueKind::InvalidScope { in_class: false },
2115            IssueKind::NonStaticSelfCall {
2116                class: s(),
2117                method: s(),
2118            },
2119            IssueKind::DirectConstructorCall { class: s() },
2120            IssueKind::UndefinedVariable { name: s() },
2121            IssueKind::UndefinedFunction { name: s() },
2122            IssueKind::UndefinedMethod {
2123                class: s(),
2124                method: s(),
2125            },
2126            IssueKind::UndefinedClass { name: s() },
2127            IssueKind::NotAnInterface { name: s() },
2128            IssueKind::UndefinedProperty {
2129                class: s(),
2130                property: s(),
2131            },
2132            IssueKind::UndefinedConstant { name: s() },
2133            IssueKind::InaccessibleClassConstant {
2134                class: s(),
2135                constant: s(),
2136            },
2137            IssueKind::PossiblyUndefinedVariable { name: s() },
2138            IssueKind::UndefinedTrait { name: s() },
2139            IssueKind::ParentNotFound,
2140            IssueKind::NullArgument {
2141                param: s(),
2142                fn_name: s(),
2143            },
2144            IssueKind::NullPropertyFetch { property: s() },
2145            IssueKind::NullMethodCall { method: s() },
2146            IssueKind::NullArrayAccess,
2147            IssueKind::PossiblyNullArgument {
2148                param: s(),
2149                fn_name: s(),
2150            },
2151            IssueKind::PossiblyInvalidArgument {
2152                param: s(),
2153                fn_name: s(),
2154                expected: s(),
2155                actual: s(),
2156            },
2157            IssueKind::PossiblyNullPropertyFetch { property: s() },
2158            IssueKind::PossiblyNullMethodCall { method: s() },
2159            IssueKind::PossiblyNullArrayAccess,
2160            IssueKind::NullableReturnStatement {
2161                expected: s(),
2162                actual: s(),
2163            },
2164            IssueKind::InvalidReturnType {
2165                expected: s(),
2166                actual: s(),
2167            },
2168            IssueKind::InvalidArgument {
2169                param: s(),
2170                fn_name: s(),
2171                expected: s(),
2172                actual: s(),
2173            },
2174            IssueKind::TooFewArguments {
2175                fn_name: s(),
2176                expected: 0,
2177                actual: 0,
2178            },
2179            IssueKind::TooManyArguments {
2180                fn_name: s(),
2181                expected: 0,
2182                actual: 0,
2183            },
2184            IssueKind::InvalidNamedArgument {
2185                fn_name: s(),
2186                name: s(),
2187            },
2188            IssueKind::InvalidNamedArguments { fn_name: s() },
2189            IssueKind::InvalidPassByReference {
2190                fn_name: s(),
2191                param: s(),
2192            },
2193            IssueKind::InvalidPropertyFetch { ty: s() },
2194            IssueKind::InvalidArrayAccess { ty: s() },
2195            IssueKind::PossiblyInvalidArrayAccess { ty: s() },
2196            IssueKind::InvalidArrayAssignment { ty: s() },
2197            IssueKind::InvalidPropertyAssignment {
2198                property: s(),
2199                expected: s(),
2200                actual: s(),
2201            },
2202            IssueKind::InvalidCast { from: s(), to: s() },
2203            IssueKind::InvalidStaticInvocation {
2204                class: s(),
2205                method: s(),
2206            },
2207            IssueKind::InvalidOperand {
2208                op: s(),
2209                left: s(),
2210                right: s(),
2211            },
2212            IssueKind::PossiblyInvalidOperand {
2213                op: s(),
2214                left: s(),
2215                right: s(),
2216            },
2217            IssueKind::PossiblyNullOperand { op: s(), ty: s() },
2218            IssueKind::DivisionByZero { op: s() },
2219            IssueKind::RawObjectIteration { ty: s() },
2220            IssueKind::PossiblyRawObjectIteration { ty: s() },
2221            IssueKind::MismatchingDocblockReturnType {
2222                declared: s(),
2223                inferred: s(),
2224            },
2225            IssueKind::MismatchingDocblockParamType {
2226                param: s(),
2227                declared: s(),
2228                inferred: s(),
2229            },
2230            IssueKind::TypeCheckMismatch {
2231                var: s(),
2232                expected: s(),
2233                actual: s(),
2234            },
2235            IssueKind::Trace {
2236                variable: s(),
2237                type_info: s(),
2238            },
2239            IssueKind::InvalidArrayOffset {
2240                expected: s(),
2241                actual: s(),
2242            },
2243            IssueKind::NonExistentArrayOffset { key: s() },
2244            IssueKind::PossiblyInvalidArrayOffset {
2245                expected: s(),
2246                actual: s(),
2247            },
2248            IssueKind::DuplicateArrayKey { key: s() },
2249            IssueKind::RedundantCondition { ty: s() },
2250            IssueKind::RedundantCast { from: s(), to: s() },
2251            IssueKind::UnnecessaryVarAnnotation { var: s() },
2252            IssueKind::TypeDoesNotContainType {
2253                left: s(),
2254                right: s(),
2255            },
2256            IssueKind::UnusedVariable { name: s() },
2257            IssueKind::UnusedParam { name: s() },
2258            IssueKind::UnreachableCode,
2259            IssueKind::UnhandledMatchCondition { detail: s() },
2260            IssueKind::UnusedMethod {
2261                class: s(),
2262                method: s(),
2263            },
2264            IssueKind::UnusedProperty {
2265                class: s(),
2266                property: s(),
2267            },
2268            IssueKind::UnusedFunction { name: s() },
2269            IssueKind::UnusedForeachValue { name: s() },
2270            IssueKind::UnusedClass { class: s() },
2271            IssueKind::UnusedSuppress { kind: s() },
2272            IssueKind::ArgumentTypeCoercion {
2273                param: s(),
2274                fn_name: s(),
2275                expected: s(),
2276                actual: s(),
2277            },
2278            IssueKind::PropertyTypeCoercion {
2279                property: s(),
2280                expected: s(),
2281                actual: s(),
2282            },
2283            IssueKind::ImpurePropertyAssignment { property: s() },
2284            IssueKind::ImpureMethodCall { method: s() },
2285            IssueKind::ImpureGlobalVariable { variable: s() },
2286            IssueKind::ImpureStaticVariable { variable: s() },
2287            IssueKind::ImpureStaticPropertyAssignment {
2288                class: s(),
2289                property: s(),
2290            },
2291            IssueKind::ImpureFunctionCall { fn_name: s() },
2292            IssueKind::ImmutablePropertyModification { property: s() },
2293            IssueKind::ReadonlyPropertyAssignment {
2294                class: s(),
2295                property: s(),
2296            },
2297            IssueKind::UnimplementedAbstractMethod {
2298                class: s(),
2299                method: s(),
2300            },
2301            IssueKind::UnimplementedInterfaceMethod {
2302                class: s(),
2303                interface: s(),
2304                method: s(),
2305            },
2306            IssueKind::MethodSignatureMismatch {
2307                class: s(),
2308                method: s(),
2309                detail: s(),
2310            },
2311            IssueKind::OverriddenMethodAccess {
2312                class: s(),
2313                method: s(),
2314            },
2315            IssueKind::OverriddenPropertyAccess {
2316                class: s(),
2317                property: s(),
2318            },
2319            IssueKind::PropertyTypeRedeclarationMismatch {
2320                class: s(),
2321                property: s(),
2322                expected: s(),
2323                actual: s(),
2324            },
2325            IssueKind::ReadonlyPropertyRedeclarationMismatch {
2326                parent_class: s(),
2327                class: s(),
2328                property: s(),
2329                parent_readonly: true,
2330            },
2331            IssueKind::StaticPropertyRedeclarationMismatch {
2332                parent_class: s(),
2333                class: s(),
2334                property: s(),
2335                parent_static: true,
2336            },
2337            IssueKind::BackedEnumCaseTypeMismatch {
2338                enum_name: s(),
2339                case_name: s(),
2340                expected: s(),
2341                actual: s(),
2342            },
2343            IssueKind::InvalidExtendClass {
2344                parent: s(),
2345                child: s(),
2346            },
2347            IssueKind::FinalMethodOverridden {
2348                class: s(),
2349                method: s(),
2350                parent: s(),
2351            },
2352            IssueKind::AbstractInstantiation { class: s() },
2353            IssueKind::AbstractMethodCall {
2354                class: s(),
2355                method: s(),
2356            },
2357            IssueKind::InterfaceInstantiation { class: s() },
2358            IssueKind::InvalidOverride {
2359                class: s(),
2360                method: s(),
2361                detail: s(),
2362            },
2363            IssueKind::CircularInheritance { class: s() },
2364            IssueKind::TaintedInput { sink: s() },
2365            IssueKind::TaintedHtml,
2366            IssueKind::TaintedSql,
2367            IssueKind::TaintedShell,
2368            IssueKind::TaintedLlmPrompt,
2369            IssueKind::InvalidTemplateParam {
2370                name: s(),
2371                expected_bound: s(),
2372                actual: s(),
2373            },
2374            IssueKind::ShadowedTemplateParam { name: s() },
2375            IssueKind::DeprecatedCall {
2376                name: s(),
2377                message: None,
2378            },
2379            IssueKind::DeprecatedProperty {
2380                class: s(),
2381                property: s(),
2382                message: None,
2383            },
2384            IssueKind::DeprecatedConstant {
2385                class: s(),
2386                constant: s(),
2387                message: None,
2388            },
2389            IssueKind::DeprecatedInterface {
2390                name: s(),
2391                message: None,
2392            },
2393            IssueKind::DeprecatedTrait {
2394                name: s(),
2395                message: None,
2396            },
2397            IssueKind::DeprecatedMethodCall {
2398                class: s(),
2399                method: s(),
2400                message: None,
2401            },
2402            IssueKind::DeprecatedMethod {
2403                class: s(),
2404                method: s(),
2405                message: None,
2406            },
2407            IssueKind::DeprecatedClass {
2408                name: s(),
2409                message: None,
2410            },
2411            IssueKind::InternalMethod {
2412                class: s(),
2413                method: s(),
2414            },
2415            IssueKind::MissingReturnType { fn_name: s() },
2416            IssueKind::MissingClosureReturnType,
2417            IssueKind::MissingParamType {
2418                fn_name: s(),
2419                param: s(),
2420            },
2421            IssueKind::MissingPropertyType {
2422                class: s(),
2423                property: s(),
2424            },
2425            IssueKind::MissingThrowsDocblock { class: s() },
2426            IssueKind::InvalidDocblock { message: s() },
2427            IssueKind::MixedArgument {
2428                param: s(),
2429                fn_name: s(),
2430            },
2431            IssueKind::MixedAssignment { var: s() },
2432            IssueKind::MixedMethodCall { method: s() },
2433            IssueKind::UnsupportedReferenceUsage,
2434            IssueKind::NoInterfaceProperties { property: s() },
2435            IssueKind::UndefinedDocblockClass { name: s() },
2436            IssueKind::MissingConstructor { class: s() },
2437            IssueKind::MixedFunctionCall,
2438            IssueKind::MixedReturnStatement { declared: s() },
2439            IssueKind::MixedPropertyFetch { property: s() },
2440            IssueKind::MixedPropertyAssignment { property: s() },
2441            IssueKind::MixedArrayAccess,
2442            IssueKind::MixedArrayOffset,
2443            IssueKind::MixedClone,
2444            IssueKind::InvalidClone { ty: s() },
2445            IssueKind::PossiblyInvalidClone { ty: s() },
2446            IssueKind::InvalidToString { class: s() },
2447            IssueKind::InvalidTraitUse {
2448                trait_name: s(),
2449                reason: s(),
2450            },
2451            IssueKind::ParseError { message: s() },
2452            IssueKind::InvalidThrow { ty: s() },
2453            IssueKind::InvalidCatch { ty: s() },
2454            IssueKind::UnreachableCatch {
2455                ty: s(),
2456                shadowed_by: s(),
2457            },
2458            IssueKind::ImplicitToStringCast { class: s() },
2459            IssueKind::ImplicitFloatToIntCast { from: s() },
2460            IssueKind::WrongCaseFunction {
2461                used: s(),
2462                canonical: s(),
2463            },
2464            IssueKind::WrongCaseMethod {
2465                class: s(),
2466                used: s(),
2467                canonical: s(),
2468            },
2469            IssueKind::WrongCaseClass {
2470                used: s(),
2471                canonical: s(),
2472            },
2473            IssueKind::InvalidAttribute { message: s() },
2474            IssueKind::UndefinedAttributeClass { name: s() },
2475            IssueKind::ForbiddenCode { message: s() },
2476            IssueKind::DuplicateClass { name: s() },
2477            IssueKind::DuplicateInterface { name: s() },
2478            IssueKind::DuplicateTrait { name: s() },
2479            IssueKind::DuplicateEnum { name: s() },
2480            IssueKind::DuplicateFunction { name: s() },
2481        ]
2482    }
2483
2484    #[test]
2485    fn codes_have_expected_shape() {
2486        for kind in one_of_each() {
2487            let code = kind.code();
2488            assert!(
2489                code.len() == 7
2490                    && code.starts_with("MIR")
2491                    && code[3..].chars().all(|c| c.is_ascii_digit()),
2492                "code {code:?} for {} does not match MIR####",
2493                kind.name(),
2494            );
2495        }
2496    }
2497
2498    #[test]
2499    fn codes_are_unique() {
2500        let kinds = one_of_each();
2501        let mut seen: HashSet<&'static str> = HashSet::new();
2502        for kind in &kinds {
2503            assert!(
2504                seen.insert(kind.code()),
2505                "duplicate code {} (variant {})",
2506                kind.code(),
2507                kind.name(),
2508            );
2509        }
2510    }
2511
2512    #[test]
2513    fn display_includes_code() {
2514        let issue = Issue::new(
2515            IssueKind::UndefinedClass {
2516                name: "Foo".to_string(),
2517            },
2518            Location {
2519                file: Arc::from("src/x.php"),
2520                line: 1,
2521                line_end: 1,
2522                col_start: 0,
2523                col_end: 3,
2524            },
2525        );
2526        // Strip ANSI escape sequences so the assertion isn't dependent on
2527        // owo-colors' tty detection.
2528        let raw = format!("{issue}");
2529        let stripped: String = {
2530            let mut out = String::new();
2531            let mut chars = raw.chars();
2532            while let Some(c) = chars.next() {
2533                if c == '\u{1b}' {
2534                    for c2 in chars.by_ref() {
2535                        if c2 == 'm' {
2536                            break;
2537                        }
2538                    }
2539                } else {
2540                    out.push(c);
2541                }
2542            }
2543            out
2544        };
2545        assert!(
2546            stripped.contains("error[MIR0005] UndefinedClass:"),
2547            "Display output missing code/name segment: {stripped:?}",
2548        );
2549    }
2550
2551    #[test]
2552    fn default_severity_for_code_round_trips() {
2553        for kind in one_of_each() {
2554            let code = kind.code();
2555            assert_eq!(
2556                IssueKind::default_severity_for_code(code),
2557                Some(kind.default_severity()),
2558                "severity mismatch for {code} (variant {})",
2559                kind.name(),
2560            );
2561        }
2562    }
2563
2564    #[test]
2565    fn default_severity_for_code_unknown_returns_none() {
2566        assert_eq!(IssueKind::default_severity_for_code("MIR9999"), None);
2567        assert_eq!(IssueKind::default_severity_for_code(""), None);
2568        assert_eq!(IssueKind::default_severity_for_code("mir0001"), None);
2569    }
2570
2571    /// Guards against forgetting to add a new variant to `one_of_each()`.
2572    /// If you add a variant, add it to `one_of_each()` *and* bump this count.
2573    #[test]
2574    fn one_of_each_has_every_variant() {
2575        // If this assertion fires after you added a new variant, also add it
2576        // to `one_of_each()` so the uniqueness and shape tests cover it.
2577        assert_eq!(one_of_each().len(), 149);
2578    }
2579}