Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
f74c429
static calls, property fetching
Hidanio Mar 11, 2025
6dab80f
refactoring
Hidanio Mar 11, 2025
9496665
test fix
Hidanio Mar 11, 2025
040d6fe
static calls as arguments nullsafety
Hidanio Mar 12, 2025
f59c9ea
function calling nullability
Hidanio Mar 12, 2025
34b1caa
fix and tests
Hidanio Mar 12, 2025
a283fd9
nolint
Hidanio Mar 12, 2025
76ee725
separated nullsafety rules
Hidanio Mar 12, 2025
528f146
doc update
Hidanio Mar 12, 2025
a1abd5f
kphp toggle
Hidanio Mar 18, 2025
2bc89de
better way to handle typing
Hidanio Mar 19, 2025
77c4577
improved type inferring for null and false in conditions identical
Hidanio Mar 23, 2025
f0cedb6
new rules and refactoring
Hidanio Mar 23, 2025
aac362a
comments removing
Hidanio Mar 23, 2025
4e5a3c5
test fixes
Hidanio Mar 23, 2025
a772edc
draft for false-safety tests
Hidanio Mar 23, 2025
9e3f156
implemented safety call for func args (types checking), new tests
Hidanio Mar 28, 2025
e30fc32
Merge branch 'master' into hidanio/safety_call_inferring
Hidanio Mar 28, 2025
25c97f0
better inferring for type by is_ condition
Hidanio Mar 30, 2025
c6c641c
test fix stubs
Hidanio Mar 31, 2025
71749cc
updated stubs
Hidanio Mar 31, 2025
f7d6536
logic for is_object and test
Hidanio Mar 31, 2025
f1a5174
revert
Hidanio Mar 31, 2025
f32a992
inherit doc type support
Hidanio Mar 31, 2025
a94e225
refactoring and type forcing improving
Hidanio Apr 1, 2025
184a9c1
refactoring
Hidanio Apr 1, 2025
16b28c5
golden tests update
Hidanio Apr 1, 2025
7625e35
test fixes and better error naming
Hidanio Apr 4, 2025
da60523
variadic fix
Hidanio Apr 4, 2025
560eb35
more cases
Hidanio Apr 8, 2025
0476335
race condition
Hidanio Apr 9, 2025
cd3869a
logic extern
Hidanio Apr 9, 2025
2d9eb7a
refactoring
Hidanio Apr 9, 2025
4b363da
closure & callable
Hidanio Apr 10, 2025
41dcb56
grammar fix
Hidanio Apr 14, 2025
80aa152
review fix
Hidanio Apr 15, 2025
e83321c
tests for map.go
Hidanio Apr 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions docs/checkers_doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -1252,7 +1252,7 @@ test($arr[1]);

#### Compliant code:
```php
reported not safety call
reported not safe call call
```
<p><br></p>

Expand Down Expand Up @@ -1303,7 +1303,7 @@ test(testNullable());

#### Compliant code:
```php
reported not safety call
reported not safe call call
```
<p><br></p>

Expand All @@ -1321,7 +1321,7 @@ test(list($a) = [null]);

#### Compliant code:
```php
reported not safety call
reported not safe call call
```
<p><br></p>

Expand All @@ -1346,7 +1346,7 @@ echo $user->name;

#### Compliant code:
```php
reported not safety call
reported not safe call call
```
<p><br></p>

Expand Down Expand Up @@ -1374,7 +1374,7 @@ test(A::hello());

#### Compliant code:
```php
reported not safety call
reported not safe call call
```
<p><br></p>

Expand All @@ -1393,7 +1393,7 @@ function f(A $klass);

#### Compliant code:
```php
reported not safety call with null in variable.
reported not safe call call with null in variable.
```
<p><br></p>

Expand All @@ -1413,7 +1413,7 @@ $getUserOrNull()->test();

#### Compliant code:
```php
reported not safety function call
reported not safe call function call
```
<p><br></p>

Expand All @@ -1438,7 +1438,7 @@ echo $user->name;

#### Compliant code:
```php
reported not safety call
reported not safe call call
```
<p><br></p>

Expand Down Expand Up @@ -1466,7 +1466,7 @@ test(A::hello());

#### Compliant code:
```php
reported not safety static function call
reported not safe call static function call
```
<p><br></p>

Expand All @@ -1488,7 +1488,7 @@ echo $user->name;

#### Compliant code:
```php
reported not safety call with null in variable.
reported not safe call call with null in variable.
```
<p><br></p>

Expand Down
205 changes: 205 additions & 0 deletions src/linter/and_walker.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ func (a *andWalker) EnterNode(w ir.Node) (res bool) {
case *ir.ParenExpr:
return true

case *ir.SimpleVar:
a.handleVariableCondition(n)

case *ir.FunctionCallExpr:
// If the absence of a function or method is being
// checked, then nothing needs to be done.
Expand Down Expand Up @@ -72,6 +75,23 @@ func (a *andWalker) EnterNode(w ir.Node) (res bool) {
}
}

switch {
case nm.Value == `is_int`:
a.handleTypeCheckCondition("int", n.Args)
case nm.Value == `is_float`:
a.handleTypeCheckCondition("float", n.Args)
case nm.Value == `is_string`:
a.handleTypeCheckCondition("string", n.Args)
case nm.Value == `is_object`:
a.handleTypeCheckCondition("object", n.Args)
case nm.Value == `is_array`:
a.handleTypeCheckCondition("array", n.Args)
case nm.Value == `is_null`:
a.handleTypeCheckCondition("null", n.Args)
case nm.Value == `is_resource`:
a.handleTypeCheckCondition("resource", n.Args)
}

case *ir.BooleanAndExpr:
a.path.Push(n)
n.Left.Walk(a)
Expand Down Expand Up @@ -191,6 +211,14 @@ func (a *andWalker) EnterNode(w ir.Node) (res bool) {
// TODO: actually this needs to be present inside if body only
}

case *ir.NotIdenticalExpr:
a.handleConditionSafety(n.Left, n.Right, false)
a.handleConditionSafety(n.Right, n.Left, false)

case *ir.IdenticalExpr:
a.handleConditionSafety(n.Left, n.Right, true)
a.handleConditionSafety(n.Right, n.Left, true)

case *ir.BooleanNotExpr:
a.inNot = true

Expand Down Expand Up @@ -219,6 +247,183 @@ func (a *andWalker) EnterNode(w ir.Node) (res bool) {
return res
}

func (a *andWalker) handleVariableCondition(variable *ir.SimpleVar) {
if !a.b.ctx.sc.HaveVar(variable) {
return
}

currentType := a.exprType(variable) // nolint:staticcheck
if a.inNot {
currentType = a.exprTypeInContext(a.trueContext, variable)
} else {
currentType = a.exprTypeInContext(a.falseContext, variable)
}

var trueType, falseType types.Map

// First, handle "null": if currentType contains "null", then in the true branch we remove it,
// and in the false branch we narrow to "null"
if currentType.Contains("null") {
trueType = currentType.Clone().Erase("null")
falseType = types.NewMap("null")
} else {
trueType = currentType.Clone()
falseType = currentType.Clone()
}

// Next, handle booleans
// If currentType contains any boolean-related literal ("bool", "true", "false"),
// then we want to narrow them:
// - If there are non-boolean parts (e.g. "User") in the union, they are always truthy
// In that case, true branch becomes nonBool ∪ {"true"} and false branch becomes {"false"}
// - If only the boolean part is present, then narrow to {"true"} and {"false"} respectively
if currentType.Contains("bool") || currentType.Contains("true") || currentType.Contains("false") {
nonBool := currentType.Clone().Erase("bool").Erase("true").Erase("false")
if nonBool.Len() > 0 {
if currentType.Contains("bool") || currentType.Contains("true") {
trueType = nonBool.Union(types.NewMap("true"))
} else {
trueType = nonBool
}
falseType = types.NewMap("false")
} else {
trueType = types.NewMap("true")
falseType = types.NewMap("false")
}
}

// Note: For other types (e.g. int, string, array), our type system doesn't include literal values,
// so we don't perform additional narrowing

// If we are in the "not" context (i.e. if(!$variable)), swap the branches
if a.inNot {
trueType, falseType = falseType, trueType
}

a.trueContext.sc.ReplaceVar(variable, trueType, "type narrowing for "+variable.Name, meta.VarAlwaysDefined)
a.falseContext.sc.ReplaceVar(variable, falseType, "type narrowing for "+variable.Name, meta.VarAlwaysDefined)
}

func (a *andWalker) handleTypeCheckCondition(expectedType string, args []ir.Node) {
for _, arg := range args {
argument, ok := arg.(*ir.Argument)
if !ok {
continue
}
variable, ok := argument.Expr.(*ir.SimpleVar)
if !ok {
continue
}

// Traverse the variable to ensure it exists, since this variable
// will be added to the context later
a.b.handleVariable(variable)

// Get the current type of the variable from the appropriate context
currentType := a.exprType(variable) // nolint:staticcheck
if a.inNot {
currentType = a.exprTypeInContext(a.trueContext, variable)
} else {
currentType = a.exprTypeInContext(a.falseContext, variable)
}

var trueType, falseType types.Map

switch expectedType {
case "bool":
// For bool: consider possible literal types "bool", "true" and "false"
boolMerge := types.MergeMaps(types.NewMap("bool"), types.NewMap("true"), types.NewMap("false"))
intersection := currentType.Intersect(boolMerge)
if intersection.Empty() {
// If there is no explicit bool subtype, then the positive branch becomes simply "bool"
trueType = types.NewMap("bool")
} else {
// Otherwise, keep exactly those literals that were present in the current type
trueType = intersection
}
// Negative branch: remove all bool subtypes
falseType = currentType.Clone().Erase("bool").Erase("true").Erase("false")
case "object":
// For is_object: keep only keys that are not considered primitive
keys := currentType.Keys()
var objectKeys []string
for _, k := range keys {
switch k {
case "int", "float", "string", "bool", "null", "true", "false", "mixed", "callable", "resource", "void", "iterable", "never":
// Skip primitive types
continue
default:
objectKeys = append(objectKeys, k)
}
}
if len(objectKeys) == 0 {
trueType = types.NewMap("object")
} else {
trueType = types.NewEmptyMap(1)
for _, k := range objectKeys {
trueType = trueType.Union(types.NewMap(k))
}
}
falseType = currentType.Clone()
for _, k := range objectKeys {
falseType = falseType.Erase(k)
}
default:
// Standard logic for other types
trueType = types.NewMap(expectedType)
falseType = currentType.Clone().Erase(expectedType)
}

if a.inNot {
trueType, falseType = falseType, trueType
}

a.trueContext.sc.ReplaceVar(variable, trueType, "type narrowing for "+expectedType, meta.VarAlwaysDefined)
a.falseContext.sc.ReplaceVar(variable, falseType, "type narrowing for "+expectedType, meta.VarAlwaysDefined)
}
}

func (a *andWalker) handleConditionSafety(left ir.Node, right ir.Node, identical bool) {
variable, ok := left.(*ir.SimpleVar)
if !ok {
return
}

constValue, ok := right.(*ir.ConstFetchExpr)
if !ok || (constValue.Constant.Value != "false" && constValue.Constant.Value != "null") {
return
}

// We need to traverse the variable here to check that
// it exists, since this variable will be added to the
// context later.
a.b.handleVariable(variable)

currentVar, isGotVar := a.b.ctx.sc.GetVar(variable)
if !isGotVar {
return
}

var currentType types.Map
if a.inNot {
currentType = a.exprTypeInContext(a.trueContext, variable)
} else {
currentType = a.exprTypeInContext(a.falseContext, variable)
}

if constValue.Constant.Value == "false" || constValue.Constant.Value == "null" {
clearType := currentType.Erase(constValue.Constant.Value)
if identical {
a.trueContext.sc.ReplaceVar(variable, currentType.Erase(clearType.String()), "type narrowing", currentVar.Flags)
a.falseContext.sc.ReplaceVar(variable, clearType, "type narrowing", currentVar.Flags)
} else {
a.trueContext.sc.ReplaceVar(variable, clearType, "type narrowing", currentVar.Flags)
a.falseContext.sc.ReplaceVar(variable, currentType.Erase(clearType.String()), "type narrowing", currentVar.Flags)
}
return
}
}

func (a *andWalker) runRules(w ir.Node) {
kind := ir.GetNodeKind(w)
if a.b.r.anyRset != nil {
Expand Down
Loading