1
0
mirror of https://github.com/danog/psalm.git synced 2024-12-02 09:37:59 +01:00

Conditionally verify that array offsets exist (#2147)

* Check array offsets idea

* Clean up some issues

* Add a few light fixes

* Add docs
This commit is contained in:
Matthew Brown 2019-09-18 14:21:06 -04:00 committed by GitHub
parent 0ac9108814
commit 9ad6c36d9b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 183 additions and 35 deletions

View File

@ -55,6 +55,7 @@
<xs:attribute name="resolveFromConfigFile" type="xs:string" /> <xs:attribute name="resolveFromConfigFile" type="xs:string" />
<xs:attribute name="includePhpVersionsInErrorBaseline" type="xs:string" /> <xs:attribute name="includePhpVersionsInErrorBaseline" type="xs:string" />
<xs:attribute name="loadXdebugStub" type="xs:string" /> <xs:attribute name="loadXdebugStub" type="xs:string" />
<xs:attribute name="ensureArrayStringOffsetsExist" type="xs:string" />
</xs:complexType> </xs:complexType>
<xs:complexType name="ProjectFilesType"> <xs:complexType name="ProjectFilesType">

View File

@ -213,6 +213,14 @@ If not present, Psalm will only load the Xdebug stub if psalm has unloaded the e
When `true`, Psalm will load the Xdebug extension stub (as the extension is unloaded when Psalm runs). When `true`, Psalm will load the Xdebug extension stub (as the extension is unloaded when Psalm runs).
Setting to `false` prevents the stub from loading. Setting to `false` prevents the stub from loading.
#### ensureArrayStringOffsetsExist
```xml
<psalm
ensureArrayStringOffsetsExist="[bool]"
>
```
When `true`, Psalm will complain when referencing an explicit string offset on an array e.g. `$arr['foo']` without a user first asserting that it exists (either via an `isset` check or via an object-like array). Defaults to `false`.
### Running Psalm ### Running Psalm
#### autoloader #### autoloader

View File

@ -10,6 +10,7 @@
checkForThrowsDocblock="false" checkForThrowsDocblock="false"
throwExceptionOnError="0" throwExceptionOnError="0"
findUnusedCode="true" findUnusedCode="true"
ensureArrayStringOffsetsExist="false"
resolveFromConfigFile="true" resolveFromConfigFile="true"
xsi:schemaLocation="https://getpsalm.org/schema/config config.xsd" xsi:schemaLocation="https://getpsalm.org/schema/config config.xsd"
> >
@ -132,5 +133,11 @@
<directory name="tests"/> <directory name="tests"/>
</errorLevel> </errorLevel>
</InternalMethod> </InternalMethod>
<PossiblyUndefinedArrayOffset>
<errorLevel type="suppress">
<directory name="tests"/>
</errorLevel>
</PossiblyUndefinedArrayOffset>
</issueHandlers> </issueHandlers>
</psalm> </psalm>

View File

@ -321,6 +321,11 @@ class Config
*/ */
public $infer_property_types_from_constructor = true; public $infer_property_types_from_constructor = true;
/**
* @var bool
*/
public $ensure_array_string_offsets_exist = false;
/** /**
* @var array<string, bool> * @var array<string, bool>
*/ */
@ -691,6 +696,7 @@ class Config
'ignoreInternalFunctionNullReturn' => 'ignore_internal_nullable_issues', 'ignoreInternalFunctionNullReturn' => 'ignore_internal_nullable_issues',
'includePhpVersionsInErrorBaseline' => 'include_php_versions_in_error_baseline', 'includePhpVersionsInErrorBaseline' => 'include_php_versions_in_error_baseline',
'loadXdebugStub' => 'load_xdebug_stub', 'loadXdebugStub' => 'load_xdebug_stub',
'ensureArrayStringOffsetsExist' => 'ensure_array_string_offsets_exist',
]; ];
foreach ($booleanAttributes as $xmlName => $internalName) { foreach ($booleanAttributes as $xmlName => $internalName) {

View File

@ -35,8 +35,11 @@ class ErrorBaseline
foreach ($existingIssues as $existingIssue) { foreach ($existingIssues as $existingIssue) {
$totalIssues += array_reduce( $totalIssues += array_reduce(
$existingIssue, $existingIssue,
/**
* @param array{o:int, s:array<int, string>} $existingIssue
*/
function (int $carry, array $existingIssue): int { function (int $carry, array $existingIssue): int {
return $carry + (int)$existingIssue['o']; return $carry + $existingIssue['o'];
}, },
0 0
); );

View File

@ -1099,6 +1099,7 @@ class ClassAnalyzer extends ClassLikeAnalyzer
if (!$storage->abstract if (!$storage->abstract
&& !$constructor_analyzer && !$constructor_analyzer
&& isset($storage->declaring_method_ids['__construct']) && isset($storage->declaring_method_ids['__construct'])
&& isset($storage->appearing_method_ids['__construct'])
&& $class->extends && $class->extends
) { ) {
list($constructor_declaring_fqcln) = explode('::', $storage->declaring_method_ids['__construct']); list($constructor_declaring_fqcln) = explode('::', $storage->declaring_method_ids['__construct']);

View File

@ -352,6 +352,10 @@ class FileAnalyzer extends SourceAnalyzer implements StatementsSource
} }
} }
if (!isset($this_context->vars_in_scope['$this'])) {
throw new \UnexpectedValueException('Should exist');
}
$call_context->vars_in_scope['$this'] = $this_context->vars_in_scope['$this']; $call_context->vars_in_scope['$this'] = $this_context->vars_in_scope['$this'];
$class_analyzer_to_examine->getMethodMutations($method_name, $call_context); $class_analyzer_to_examine->getMethodMutations($method_name, $call_context);

View File

@ -302,6 +302,7 @@ class FunctionAnalyzer extends FunctionLikeAnalyzer
if (isset($first_arg->inferredType)) { if (isset($first_arg->inferredType)) {
if ($first_arg->inferredType->hasArray()) { if ($first_arg->inferredType->hasArray()) {
/** @psalm-suppress PossiblyUndefinedArrayOffset */
$array_type = $first_arg->inferredType->getTypes()['array']; $array_type = $first_arg->inferredType->getTypes()['array'];
if ($array_type instanceof Type\Atomic\ObjectLike) { if ($array_type instanceof Type\Atomic\ObjectLike) {
return $array_type->getGenericValueType(); return $array_type->getGenericValueType();

View File

@ -557,7 +557,10 @@ abstract class FunctionLikeAnalyzer extends SourceAnalyzer implements Statements
)) ))
) { ) {
if ($this->function->inferredType) { if ($this->function->inferredType) {
/** @var Type\Atomic\TFn */ /**
* @psalm-suppress PossiblyUndefinedArrayOffset
* @var Type\Atomic\TFn
*/
$closure_atomic = $this->function->inferredType->getTypes()['Closure']; $closure_atomic = $this->function->inferredType->getTypes()['Closure'];
$closure_atomic->return_type = $closure_return_type; $closure_atomic->return_type = $closure_return_type;
} }

View File

@ -509,42 +509,70 @@ class ArrayFetchAnalyzer
$has_valid_offset = true; $has_valid_offset = true;
} }
} }
} elseif ((!TypeAnalyzer::isContainedBy( } else {
$codebase, $offset_type_contained_by_expected = TypeAnalyzer::isContainedBy(
$offset_type,
$expected_offset_type,
true,
$offset_type->ignore_falsable_issues,
$union_comparison_results
) && !$union_comparison_results->type_coerced_from_scalar)
|| $union_comparison_results->to_string_cast
) {
if ($union_comparison_results->type_coerced_from_mixed
&& !$offset_type->isMixed()
) {
if (IssueBuffer::accepts(
new MixedArrayTypeCoercion(
'Coercion from array offset type \'' . $offset_type->getId() . '\' '
. 'to the expected type \'' . $expected_offset_type->getId() . '\'',
new CodeLocation($statements_analyzer->getSource(), $stmt)
),
$statements_analyzer->getSuppressedIssues()
)) {
// fall through
}
} else {
$expected_offset_types[] = $expected_offset_type->getId();
}
if (TypeAnalyzer::canExpressionTypesBeIdentical(
$codebase, $codebase,
$offset_type, $offset_type,
$expected_offset_type $expected_offset_type,
)) { true,
$offset_type->ignore_falsable_issues,
$union_comparison_results
);
if ($offset_type_contained_by_expected
&& $offset_type->hasLiteralString()
&& !$expected_offset_type->hasLiteralClassString()
&& !$context->inside_isset
&& !$context->inside_unset
) {
if ($codebase->config->ensure_array_string_offsets_exist) {
if (IssueBuffer::accepts(
new PossiblyUndefinedArrayOffset(
'Possibly undefined array offset \''
. $offset_type->getId() . '\' '
. 'is risky given expected type \''
. $expected_offset_type->getId() . '\'.'
. ' Consider using isset beforehand.',
new CodeLocation($statements_analyzer->getSource(), $stmt)
),
$statements_analyzer->getSuppressedIssues()
)) {
// fall through
}
}
}
if ((!$offset_type_contained_by_expected
&& !$union_comparison_results->type_coerced_from_scalar)
|| $union_comparison_results->to_string_cast
) {
if ($union_comparison_results->type_coerced_from_mixed
&& !$offset_type->isMixed()
) {
if (IssueBuffer::accepts(
new MixedArrayTypeCoercion(
'Coercion from array offset type \'' . $offset_type->getId() . '\' '
. 'to the expected type \'' . $expected_offset_type->getId() . '\'',
new CodeLocation($statements_analyzer->getSource(), $stmt)
),
$statements_analyzer->getSuppressedIssues()
)) {
// fall through
}
} else {
$expected_offset_types[] = $expected_offset_type->getId();
}
if (TypeAnalyzer::canExpressionTypesBeIdentical(
$codebase,
$offset_type,
$expected_offset_type
)) {
$has_valid_offset = true;
}
} else {
$has_valid_offset = true; $has_valid_offset = true;
} }
} else {
$has_valid_offset = true;
} }
} }

View File

@ -354,6 +354,10 @@ class IssueBuffer
if (self::$issues_data) { if (self::$issues_data) {
usort( usort(
self::$issues_data, self::$issues_data,
/**
* @param array{file_path: string, line_from: int, column_from: int} $d1
* @param array{file_path: string, line_from: int, column_from: int} $d2
*/
function (array $d1, array $d2) : int { function (array $d1, array $d2) : int {
if ($d1['file_path'] === $d2['file_path']) { if ($d1['file_path'] === $d2['file_path']) {
if ($d1['line_from'] === $d2['line_from']) { if ($d1['line_from'] === $d2['line_from']) {

View File

@ -68,6 +68,9 @@ abstract class Report
if (!$report_options->show_info) { if (!$report_options->show_info) {
$this->issues_data = array_filter( $this->issues_data = array_filter(
$issues_data, $issues_data,
/**
* @var array{severity: string}
*/
function (array $issue_data) : bool { function (array $issue_data) : bool {
return $issue_data['severity'] !== Config::REPORT_INFO; return $issue_data['severity'] !== Config::REPORT_INFO;
} }

View File

@ -617,7 +617,10 @@ abstract class Type
$offset_defining_class = array_keys($offset_template_data)[0]; $offset_defining_class = array_keys($offset_template_data)[0];
if (!$offset_defining_class && $offset_template_data[''][0]->isSingle()) { if (!$offset_defining_class
&& isset($offset_template_data[''])
&& $offset_template_data[''][0]->isSingle()
) {
$offset_template_type = array_values($offset_template_data[''][0]->getTypes())[0]; $offset_template_type = array_values($offset_template_data[''][0]->getTypes())[0];
if ($offset_template_type instanceof Type\Atomic\TTemplateKeyOf) { if ($offset_template_type instanceof Type\Atomic\TTemplateKeyOf) {

View File

@ -1675,6 +1675,14 @@ class Union
|| isset($this->types['true']); || isset($this->types['true']);
} }
/**
* @return bool
*/
public function hasLiteralString()
{
return count($this->literal_string_types) > 0;
}
/** /**
* @return bool true if this is a int literal with only one possible value * @return bool true if this is a int literal with only one possible value
*/ */

View File

@ -8,6 +8,74 @@ class ArrayAccessTest extends TestCase
use Traits\InvalidCodeAnalysisTestTrait; use Traits\InvalidCodeAnalysisTestTrait;
use Traits\ValidCodeAnalysisTestTrait; use Traits\ValidCodeAnalysisTestTrait;
/**
* @return void
*/
public function testEnsureArrayOffsetsExist()
{
$this->expectException(\Psalm\Exception\CodeException::class);
$this->expectExceptionMessage('PossiblyUndefinedArrayOffset');
\Psalm\Config::getInstance()->ensure_array_string_offsets_exist = true;
$this->addFile(
'somefile.php',
'<?php
function takesString(string $s): void {}
/** @param array<string, string> $arr */
function takesArrayIteratorOfString(array $arr): void {
echo $arr["hello"];
}'
);
$this->analyzeFile('somefile.php', new \Psalm\Context());
}
/**
* @return void
*/
public function testEnsureArrayOffsetsExistWithIssetCheck()
{
\Psalm\Config::getInstance()->ensure_array_string_offsets_exist = true;
$this->addFile(
'somefile.php',
'<?php
function takesString(string $s): void {}
/** @param array<string, string> $arr */
function takesArrayIteratorOfString(array $arr): void {
if (isset($arr["hello"])) {
echo $arr["hello"];
}
}'
);
$this->analyzeFile('somefile.php', new \Psalm\Context());
}
/**
* @return void
*/
public function testDontEnsureArrayOffsetsExist()
{
\Psalm\Config::getInstance()->ensure_array_string_offsets_exist = false;
$this->addFile(
'somefile.php',
'<?php
function takesString(string $s): void {}
/** @param array<string, string> $arr */
function takesArrayIteratorOfString(array $arr): void {
echo $arr["hello"];
}'
);
$this->analyzeFile('somefile.php', new \Psalm\Context());
}
/** /**
* @return iterable<string,array{string,assertions?:array<string,string>,error_levels?:string[]}> * @return iterable<string,array{string,assertions?:array<string,string>,error_levels?:string[]}>
*/ */