java-topology/docs/tickets/psalm-0001-filefilter-allowsclass-in-array.md
russell@unturf.com bb81a1a3a0
wave4: psalm-0001 flagship patch + linter/CI/config scan survey
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).
2026-04-24 17:21:51 -04:00

2.7 KiB
Raw Blame History

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×)