psalm-0001: FileFilter.allowsClass runs in_array() on every class the analyzer visits. For C classes and F filter entries, per-run cost is O(C*F). Fix: lazy array_fill_keys hash set; O(1) probe per class. Bench: 336x at C=F=5000. Patch + ticket + bench + intel brief ship. wave4-linter-ci-survey.md: consolidated report on 40 projects scanned across linters (eslint, biome, prettier, pylint, ruff, black, rubocop, shellcheck, stylelint, sqlfluff, phpstan, PHP_CodeSniffer, psalm, rustfmt, golangci-lint, scalafmt, hadolint, yamllint, markdownlint, ktlint, detekt), CI runners (act, buildkite-agent, tektoncd/pipeline, concourse, woodpecker), config management (aws-cdk, cdk8s, kustomize), and build tools (rollup, parcel, vite, turborepo, nx, lerna, swc, babel, gulp). Clean scans (zero HIGH+ findings): hadolint, shellcheck, gulp. Document includes per-target finding counts and 7 triage follow-ups for future waves (PHP_CodeSniffer ReDoS, ktlint spacing rule, pylint MSG_ORDER.index, black pgen2 dfa, golangci-lint migrate, tektoncd forbidden-env scan, aws-cdk region-info).
2.7 KiB
psalm-0001: FileFilter.allowsClass — O(C×F) linear scan per class analyzed
Target: vimeo/psalm
Severity: MEDIUM
CWE: CWE-407 (Inefficient Algorithmic Complexity)
MOAD: MOAD-0001 (A Sedimentary Defect)
File: src/Psalm/Config/FileFilter.php:573-584
Language: PHP
Status: open
Description
Psalm\Config\FileFilter::allowsClass() is called during static analysis once per fully-qualified class the analyzer visits. The method iterates an optional list of regex patterns, then falls back to in_array(strtolower($fq_classlike_name), $this->fq_classlike_names, true) — an O(F) linear scan of the configured class-name filter list.
For a project with C classes and a filter list of size F (common in large codebases that carefully scope Psalm analysis), per-analysis cost is O(C×F). Filter lists commonly hit hundreds of entries on large monorepos.
Root Cause
public function allowsClass(string $fq_classlike_name): bool
{
if ($this->fq_classlike_patterns) {
foreach ($this->fq_classlike_patterns as $pattern) {
if (preg_match($pattern, $fq_classlike_name)) {
return true;
}
}
}
return in_array(strtolower($fq_classlike_name), $this->fq_classlike_names, true);
}
in_array is O(F) and runs on every class the analyzer encounters. C class × F filter = O(C×F).
Fix
Pre-lowercase $this->fq_classlike_names once and store as an associative array (hash set). isset($this->fq_classlike_names_set[$lowered]) is O(1). Keep the patterns-first path unchanged.
private ?array $fq_classlike_names_set = null; // lazy O(1) lookup
public function allowsClass(string $fq_classlike_name): bool
{
if ($this->fq_classlike_patterns) {
foreach ($this->fq_classlike_patterns as $pattern) {
if (preg_match($pattern, $fq_classlike_name)) {
return true;
}
}
}
if ($this->fq_classlike_names_set === null) {
$this->fq_classlike_names_set = array_fill_keys(
array_map('strtolower', $this->fq_classlike_names),
true,
);
}
return isset($this->fq_classlike_names_set[strtolower($fq_classlike_name)]);
}
Total cost drops to O(C+F). The lazy-init ensures the hash is built once per FileFilter instance and reused.
Severity Note
Runs on every class visited during Psalm analysis. Impact scales with project size × filter-list size. A 10,000-class project with a 1,000-entry filter list goes from 10M scans to 11K hash lookups. Psalm runs are already CPU-bound; this closes an O(N²) that compounds the cost.
Complexity Gate
- C=F=1000: fixed must complete in <5ms
- k-scaling 5×: time ratio must be <17.5× (O(k) ≈5×, not O(k²) ≈25×)