From e1a7785cf40b715eec094ab5b4e22b8a0cb1b087 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Mon, 4 May 2026 11:29:52 +0000 Subject: [PATCH] feat: add ErrorContextAnalyser for sliding-window error/warning surfacing Walks Entry[] once and emits one ErrorContextProblem per ERROR or WARNING entry, attaching up to 20 entries before and 20 after as context. Overlapping windows clip the second hit's before- and after-ranges so no Entry appears in two context arrays. Caps emission at 500 hits and adds an ErrorContextTruncatedInformation when reached. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ProjectZomboid/ErrorContextAnalyser.php | 131 ++++++++++++++++++ .../ProjectZomboid/ErrorContextProblem.php | 130 +++++++++++++++++ .../ErrorContextTruncatedInformation.php | 42 ++++++ .../Analyser/ErrorContextAnalyserTest.php | 128 +++++++++++++++++ 4 files changed, 431 insertions(+) create mode 100644 src/Analyser/ProjectZomboid/ErrorContextAnalyser.php create mode 100644 src/Analysis/ProjectZomboid/ErrorContextProblem.php create mode 100644 src/Analysis/ProjectZomboid/ErrorContextTruncatedInformation.php create mode 100644 test/tests/Games/ProjectZomboid/Analyser/ErrorContextAnalyserTest.php diff --git a/src/Analyser/ProjectZomboid/ErrorContextAnalyser.php b/src/Analyser/ProjectZomboid/ErrorContextAnalyser.php new file mode 100644 index 0000000..eb07df4 --- /dev/null +++ b/src/Analyser/ProjectZomboid/ErrorContextAnalyser.php @@ -0,0 +1,131 @@ +setLog($this->log); + + $entries = []; + foreach ($this->log as $entry) { + $entries[] = $entry; + } + $count = count($entries); + + $hits = 0; + $truncated = false; + $lastEmittedIndex = -1; + + for ($i = 0; $i < $count; $i++) { + $type = $this->classify($entries[$i]); + if ($type === null) { + continue; + } + + if ($hits >= self::HIT_CAP) { + $truncated = true; + break; + } + + $beforeStart = max($lastEmittedIndex + 1, $i - self::CONTEXT_BEFORE); + if ($beforeStart > $i) { + $beforeStart = $i; + } + $afterStart = max($lastEmittedIndex + 1, $i + 1); + $afterEnd = min($count - 1, $i + self::CONTEXT_AFTER); + $afterLength = max(0, $afterEnd - $afterStart + 1); + + $analysis->addInsight((new ErrorContextProblem()) + ->setEntry($entries[$i]) + ->setType($type) + ->setEntryIndex($i + 1) + ->setBefore(array_slice($entries, $beforeStart, $i - $beforeStart)) + ->setAfter(array_slice($entries, $afterStart, $afterLength))); + + $hits++; + $lastEmittedIndex = max($lastEmittedIndex, $afterEnd); + } + + if ($truncated) { + $analysis->addInsight((new ErrorContextTruncatedInformation()) + ->setHitCap(self::HIT_CAP)); + } + + return $analysis; + } + + /** + * Classify an entry as 'error', 'warning', or null based on its Level. + * Levels at or below ERROR (EMERGENCY/ALERT/CRITICAL/ERROR) collapse + * into 'error'; WARNING alone collapses into 'warning'. Returns null + * for anything less severe so the analyser skips it. + */ + protected function classify(EntryInterface $entry): ?string + { + $level = $entry->getLevel()->asInt(); + if ($level <= Level::ERROR->asInt()) { + return 'error'; + } + if ($level === Level::WARNING->asInt()) { + return 'warning'; + } + return null; + } +} diff --git a/src/Analysis/ProjectZomboid/ErrorContextProblem.php b/src/Analysis/ProjectZomboid/ErrorContextProblem.php new file mode 100644 index 0000000..7218193 --- /dev/null +++ b/src/Analysis/ProjectZomboid/ErrorContextProblem.php @@ -0,0 +1,130 @@ +type = $type; + return $this; + } + + /** + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * @param int $entryIndex 1-based index of the hit entry within the log + * @return $this + */ + public function setEntryIndex(int $entryIndex): static + { + $this->entryIndex = $entryIndex; + return $this; + } + + /** + * @return int 1-based index of the hit entry within the log + */ + public function getEntryIndex(): int + { + return $this->entryIndex; + } + + /** + * @param EntryInterface[] $entries + * @return $this + */ + public function setBefore(array $entries): static + { + $this->before = $entries; + return $this; + } + + /** + * @return EntryInterface[] + */ + public function getBefore(): array + { + return $this->before; + } + + /** + * @param EntryInterface[] $entries + * @return $this + */ + public function setAfter(array $entries): static + { + $this->after = $entries; + return $this; + } + + /** + * @return EntryInterface[] + */ + public function getAfter(): array + { + return $this->after; + } + + /** + * Convenience accessor returning before-context, hit entry, and + * after-context as a single ordered array of at most + * ErrorContextAnalyser::CONTEXT_BEFORE + 1 + CONTEXT_AFTER = 41 + * entries. + * + * @return EntryInterface[] + */ + public function getContext(): array + { + return [...$this->before, $this->getEntry(), ...$this->after]; + } + + public function getMessage(): string + { + return sprintf( + '%s at entry %d (%d before, %d after)', + strtoupper($this->type), + $this->entryIndex, + count($this->before), + count($this->after) + ); + } + + public function isEqual(InsightInterface $insight): bool + { + return $insight instanceof self && $insight->getEntryIndex() === $this->entryIndex; + } +} diff --git a/src/Analysis/ProjectZomboid/ErrorContextTruncatedInformation.php b/src/Analysis/ProjectZomboid/ErrorContextTruncatedInformation.php new file mode 100644 index 0000000..d69ecdd --- /dev/null +++ b/src/Analysis/ProjectZomboid/ErrorContextTruncatedInformation.php @@ -0,0 +1,42 @@ +hitCap = $hitCap; + $this->setLabel('Error context'); + $this->setValue(sprintf('truncated after %d hits', $hitCap)); + return $this; + } + + /** + * @return int + */ + public function getHitCap(): int + { + return $this->hitCap; + } + + public function isEqual(InsightInterface $insight): bool + { + return $insight instanceof self; + } +} diff --git a/test/tests/Games/ProjectZomboid/Analyser/ErrorContextAnalyserTest.php b/test/tests/Games/ProjectZomboid/Analyser/ErrorContextAnalyserTest.php new file mode 100644 index 0000000..faf301e --- /dev/null +++ b/test/tests/Games/ProjectZomboid/Analyser/ErrorContextAnalyserTest.php @@ -0,0 +1,128 @@ +setLevel($level) + ->addLine(new Line($n, sprintf('line %d', $n))); + $log->addEntry($entry); + } + return $log; + } + + public function testEmitsThreeNonOverlappingWindows(): void + { + $log = $this->makeLog([10, 50, 95], 100); + $analysis = (new ErrorContextAnalyser())->setLog($log)->analyse(); + + $problems = $analysis->getFilteredInsights(ErrorContextProblem::class); + $this->assertCount(3, $problems); + + $this->assertSame(10, $problems[0]->getEntryIndex()); + $this->assertSame(50, $problems[1]->getEntryIndex()); + $this->assertSame(95, $problems[2]->getEntryIndex()); + + // First hit (entry 10): 9 entries before (1..9), 20 after (11..30). + $this->assertCount(9, $problems[0]->getBefore()); + $this->assertCount(20, $problems[0]->getAfter()); + + // Second hit (entry 50): clipped to 19 before (31..49), 20 after (51..70). + $this->assertCount(19, $problems[1]->getBefore()); + $this->assertCount(20, $problems[1]->getAfter()); + + // Third hit (entry 95): clipped to 20 before (75..94), 5 after (96..100). + $this->assertCount(20, $problems[2]->getBefore()); + $this->assertCount(5, $problems[2]->getAfter()); + + // Total window per hit never exceeds 1 + CONTEXT_BEFORE + CONTEXT_AFTER = 41. + foreach ($problems as $problem) { + $this->assertLessThanOrEqual(ErrorContextAnalyser::CONTEXT_BEFORE, count($problem->getBefore())); + $this->assertLessThanOrEqual(ErrorContextAnalyser::CONTEXT_AFTER, count($problem->getAfter())); + $this->assertLessThanOrEqual(41, count($problem->getContext())); + } + + // No entry appears in two problems' context arrays. + $seen = []; + foreach ($problems as $problem) { + foreach ([...$problem->getBefore(), ...$problem->getAfter()] as $entry) { + $id = spl_object_id($entry); + $this->assertArrayNotHasKey($id, $seen, 'Entry duplicated across problem context arrays'); + $seen[$id] = true; + } + } + } + + public function testMergesAdjacentWindowsWhenWithinContextRange(): void + { + // Errors 5 entries apart; without merge their windows would + // overlap heavily. + $log = $this->makeLog([10, 15], 50); + $analysis = (new ErrorContextAnalyser())->setLog($log)->analyse(); + + $problems = $analysis->getFilteredInsights(ErrorContextProblem::class); + $this->assertCount(2, $problems); + + // First hit: 9 before (1..9), 20 after (11..30). lastEmittedIndex=29 (0-based). + $this->assertCount(9, $problems[0]->getBefore()); + $this->assertCount(20, $problems[0]->getAfter()); + + // Second hit at entry 15 (i=14). beforeStart clamped past i so before is empty. + // afterStart=max(30, 15)=30, afterEnd=min(49, 34)=34, so after=entries 31..35 + // (5 entries, all unseen). + $this->assertCount(0, $problems[1]->getBefore()); + $this->assertCount(5, $problems[1]->getAfter()); + + // Confirm no entry appears in both problems' context arrays. + $first = [...$problems[0]->getBefore(), ...$problems[0]->getAfter()]; + $second = [...$problems[1]->getBefore(), ...$problems[1]->getAfter()]; + foreach ($second as $entry) { + $this->assertNotContains($entry, $first, 'Entry duplicated across merged windows'); + } + } + + public function testTruncatesAtHitCap(): void + { + // 600 consecutive ERROR entries — analyser should cap emission at + // HIT_CAP and add exactly one truncation Information. + $log = $this->makeLog(range(1, 600), 600); + $analysis = (new ErrorContextAnalyser())->setLog($log)->analyse(); + + $problems = $analysis->getFilteredInsights(ErrorContextProblem::class); + $this->assertCount(ErrorContextAnalyser::HIT_CAP, $problems); + + $information = $analysis->getFilteredInsights(ErrorContextTruncatedInformation::class); + $this->assertCount(1, $information); + $this->assertSame(ErrorContextAnalyser::HIT_CAP, $information[0]->getHitCap()); + } +}