Skip to content

feat(#11): per-file index cache, PhpStorm recipe, v0.1.0 docs - #12

Merged
larspohlmann merged 14 commits into
developfrom
feature/11-cache-and-release
Aug 11, 2026
Merged

larspohlmann merged 14 commits into
developfrom
feature/11-cache-and-release

Conversation

@larspohlmann

Copy link
Copy Markdown
Owner

Closes #11.

Phase 6: a default-on per-file index cache, the PhpStorm IDE recipe, docs polish, and the v0.1.0 release prep.

Index cache

  • Per-file FileIndexer parses and classifies one source file into a self-contained, serializable FileIndex, using a fresh visitor per call — the seam that makes a file independently cacheable.
  • FileIndexCache persists a serialized FileIndex keyed by the source's real path, invalidated by mtime/size/app-version/payload-format. Transparency is total: every failure path is a silent miss, never an exception, and the cache never changes the merged result.
  • Default-on at .phptramp.cache/, with --no-cache and a cache config key to relocate it.

Docs / IDE

  • PhpStorm File Watcher recipe (docs/phpstorm.md) and a docs release pass.
  • Post-v0.1 roadmap and Phase 6 status in docs/plan.md.

Quality pass (this branch's later commits)

  • Merge per-file indexes in place instead of array_merge in a loop (drops an O(n²) on the warm path).
  • Skip the cold-run stat() until a cache entry is actually found.
  • Fold the suppression trio into a SuppressionParts value object — FileIndex drops from five fields to three, and Indexer::index() folds a list of parts in one pass instead of three parallel accumulators.
  • Drop IndexerFactory: dumpIndex() now delegates to buildIndex(), so the cache decision reads inline at one site (PHPMD stays green).
  • Ignore .phptramp.cache/ at any depth, not just the repo root.

Verification

  • composer check green (cs + stan + md + test) — 403 tests.
  • composer infection:diff green — MSI 94% (gate is 80), 100% mutation code coverage.

Cache-and-release phase against the shipped Phase 5 interfaces. The
headline decision is evidence-based: cold indexing measured at ~19.6s
for the 600k-LOC vendor tree (~1.6s per 50k LOC), so the per-file cache
alone meets the warm-run IDE target and parallel indexing is deferred
to the post-v0.1 roadmap with the numbers recorded — per the master
plan's own 'performance last, when profiling has real data' rule. The
Indexer refactor into per-file FileIndexer/FileIndex units is a pure
refactor gated on byte-identical composer tramp output; the cache
treats every failure as a silent miss so it can never change findings.
Release follows CLAUDE.md's git-flow tag-triggered workflow literally,
with Packagist submission marked as a maintainer-only action.
Split the single accumulating IndexingVisitor traversal into per-file
units: FileIndexer parses and classifies one file with a fresh visitor and
traverser, producing a serializable FileIndex; Indexer becomes a merge
orchestrator that combines per-file results in input order (last FQMN/FQCN
wins, suppression parts concatenated) and builds one SuppressionIndex from
the concatenation. No behaviour change — the 374-test suite plus 5 new
FileIndexerTest cases pass and composer tramp is byte-identical to the
captured baseline (sha 07982cd5).

IndexingVisitor::suppressions() and SuppressionCollector::suppressions()
become dead code and are removed; SuppressionCollector::parts() exposes the
raw three-part shape the Indexer now merges. The dead @see
IndexingVisitor::suppressions() in SuppressionIndex.php is repointed to
Indexer, and two accidental // phptramp-ignore markers in docblock
documentation (which silently created stale line suppressions) are reworded
out of the prose.
Adds FileIndexCache, a transparency-critical per-source-file cache for
serialized FileIndex payloads. Entries are keyed by sha1(realpath), and
every read validates format, Application::VERSION, source path, mtime,
and size — any drift, corrupt payload, or unreadable entry degrades to a
silent miss (null), never an exception. put() creates the directory
lazily and ignores write failures (unwritable dir, full disk) so the
cache can never block analysis.

unserialize() is scoped to an explicit allowed_classes whitelist of the
FileIndex value-object set; no arbitrary object can be materialized
from a tampered entry.
Wires Task 2's FileIndexCache into the CLI as the default-on per-file
index cache at `.phptramp.cache/` (overridable via the `cache` config key,
disabled via `--no-cache`). No analysis semantics change: `composer tramp`
self-gate stdout stays byte-identical (sha256 07982cd5...) across cold,
warm, and `--no-cache` runs.

- Options gains readonly `noCache` (default false) and `cacheDir`
  (default `.phptramp.cache`) at the end of the constructor so existing
  positional args stay stable.
- `--no-cache` is a BoolFlags unit member (keeps ArgvParser under PHPMD's
  field-count ceiling); no `--cache` CLI value flag — the dir only comes
  from config or the default, per the brief.
- ConfigLoader maps `cache` (string) to `cacheDir`; non-string throws
  ConfigException (same shape as baseline/format). Path is not resolved,
  matching `exclude`.
- Indexer takes an optional `?FileIndexCache` (default null = uncached);
  every existing `new Indexer()` caller is unaffected. On a hit the parse
  is skipped; on a miss parse + put. mtime/size identity invalidates on
  edit, so the aggregate-ParseException contract is unchanged.
- IndexerFactory (new, 1-method final class) builds the Indexer from
  Options; extracted out of Application because the on/off ternary tipped
  Application's class-complexity to PHPMD's 50 threshold.
- FixtureTest runs with --no-cache so fixtures stay hermetic (they pin
  semantics, not cache behavior) and don't litter .phptramp.cache/ into
  committed fixture dirs.

Manual sanity (bin/phptramp --folder vendor --limit 99 --format summary):
  cold 19.35s (4610 files parsed)  ->  warm 4.46s (4610 cache hits)
Indexing drops to ~0 warm; the residual 4.46s is the unchanged
chain-resolution + reporting pipeline. Cold vs warm stdout byte-identical.
array_merge rebuilds the whole accumulated method/class map on every
iteration, so merging F files is O(total entries^2). Append each file's
entries in place instead — O(total), same later-file-wins semantics
(keys are FQMN/FQCN/file paths, never numeric, so no renumbering).
get() stat-ed the source file before checking whether a cache entry even
existed, so every file on a cold run paid a stat it never used. Read the
entry first and stat only once a payload is in hand; a file with no entry
now skips the syscall entirely. Pure reorder — behaviour is identical on
every path.
…e object

The suppression trio (methods, params, ignore lines) travelled together as
three loose parallel fields on FileIndex, an array{methods, params, lines}
shape redeclared in SuppressionCollector and IndexingVisitor, and three
accumulators in Indexer::index() — only ever to build one SuppressionIndex.

Bundle them into a SuppressionParts value object that owns the merge
(concatenate methods and params, union ignore lines by file) and the
SuppressionIndex construction. FileIndex drops from five fields to three,
the redeclared array shape collapses to a type, and Indexer folds a list
of parts in one pass instead of maintaining three accumulators.

FileIndex now nests a SuppressionParts, so the cache learns the new class
(ALLOWED_CLASSES) and bumps FORMAT 1->2; entries written under the old
format silently miss and re-parse.
The anchored /.phptramp.cache/ pattern missed cache dirs the tool writes
under subdirectories — e.g. tests/fixtures/config-driven/.phptramp.cache/
left behind by a config-driven fixture run. Unanchor it so every cache
dir is ignored wherever phptramp creates one.
IndexerFactory existed only to hold one ternary and keep it out of
Application, but it never grew past a thin wrapper — unlike its sibling
ReporterFactory, which genuinely dispatches over six report formats.

That ternary needed its own home only because dumpIndex duplicated
buildIndex's locate-then-index body, so the indexer was built at two
sites. Make dumpIndex delegate to buildIndex; construction collapses to
one site where the cache decision reads inline. PHPMD stays green because
Application gains one branch while dumpIndex sheds the duplicated one, so
the codesize ceiling the factory was routing around is never reached.
testGetReturnsNullWhenSourceSizeChanges restores the source mtime to the
second the file already carries — a no-op touch. On PHP 8.2 a no-op touch
does not flush PHP's stat cache, so get()'s stat reported the pre-write
size and the entry looked current: a false pass on platforms that flush
anyway (8.3/8.4) and a hard failure on 8.2. Flush the stat cache
explicitly so the stat reflects the grown file, exactly as the fresh
process a real re-run gets. Production is unaffected: indexFile only ever
get()s a file before it put()s it, never re-statting its own write.
@larspohlmann
larspohlmann merged commit 4dba95e into develop Aug 11, 2026
5 checks passed
@larspohlmann
larspohlmann deleted the feature/11-cache-and-release branch August 14, 2026 10:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 6 — Index cache, IDE recipe, docs polish, v0.1.0 release

1 participant