SLOPSHOPPER

olo-output-truth

Makes OloEngine build and test output tell the truth: failed builds behind an exit-0 wrapper, gtest filters that select nothing, runs that died mid-test…

newguardtoastprocess
★ 17v0.1.0NOASSERTIONupdated 2026-10-10drsnuggles8/OloEngineBase/.claude/mods/olo-output-truth
A shopper browsing a rack in a slop shop
README

OloEngine

Windows Sanitizers GPU Conformance (AMD, self-hosted) Cross-Vendor Conformance Fuzz Smoke SonarCloud Scan Pre-commit checks

OloEngine is a cross-platform (Windows / Linux) C++23 interactive-application and rendering engine. It started as a follow-along of Hazel but has since grown well past that scope into a full ECS-driven engine with a physically-based OpenGL 4.6 renderer, 2D/3D physics, dual C#/Lua scripting, networking, navigation, procedural terrain and environmental systems, a gameplay/AI stack, and an ImGui editor.

Screenshots

Metallic and dielectric PBR spheres (a roughness sweep) reflecting a procedural star-nest sky:

PBR materials reflecting a procedural sky

PBR modelAnimated models3D physicsFFT ocean
PBR modelAnimated models3D physicsFFT ocean

Table of contents

Repository layout

Target / directoryWhat it is
OloEngine/The engine static library (OloEngine). All subsystems live under src/OloEngine/<Subsystem>/; platform glue under src/Platform/.
OloEditor/ImGui-based editor (OloEditor). Panels under src/, runtime assets under assets/, a sample game under SandboxProject/.
OloRuntime/Standalone game runtime (OloRuntime) that loads what the editor builds.
OloServer/Headless dedicated server (OloServer) — the one target that also runs on WSL2.
OloEngine-ScriptCore/C# scripting API library, built with dotnet build under any generator; C# scripting runs on Windows only.
OloEngine-LuaScriptCore/Lua scripting core (all platforms).
OloEngine/tests/GoogleTest suite (OloEngine-Tests).
tools/Build-time tooling, notably OloHeaderTool (code generation — see Tooling).
docs/Design docs, ADRs, build/testing guides, and agent rules — see Documentation.

Getting started

Supported platforms

  • Windows — Visual Studio 2026 (the default msvc preset) or Visual Studio 2022.
  • Linux — GCC 14+.
  • WSL2 — can compile every target and run OloServer, but not OloEditor: WSL2's software renderer only exposes OpenGL 4.5, and the editor requires a native OpenGL 4.6 GPU.

Requirements

  • CMake 4.2+ — required by CMakePresets.json (the msvc preset's Visual Studio 18 2026 generator only exists in 4.2+). The root CMakeLists.txt itself only requires 3.27, so a plain cmake -B build -G "Visual Studio 17 2022" still works without presets.
  • Python 3.10+ with the jinja2 package (needed to build glad2).
  • Vulkan SDK — for SPIR-V shader compilation.
  • Steamworks SDK — optional, and you must download it yourself: its licence forbids redistribution, so it is neither in this repo nor available through vcpkg. Needed only to work on Steam features (achievements, Steam Cloud, rich presence/overlay). Steam support is auto-detected: OLO_WITH_STEAM turns on when STEAMWORKS_SDK_ROOT points at a real SDK (a public/steam directory) and stays off otherwise — so without the SDK everything builds, runs and tests exactly as before, and with it installed you get the Steam path without passing a flag. The SDK itself is free — an ordinary Steam account and the Steamworks SDK Access Agreement, not the $100 Steam Direct publishing fee. Setup: docs/ops/build.md.

Clone

git clone https://github.com/drsnuggles8/OloEngineBase

Most third-party dependencies are resolved from the vcpkg.json manifest and built once into vcpkg's machine-global binary cache, so a second worktree restores them in seconds instead of recompiling (issue #773). VCPKG_ROOT must point at a bootstrapped vcpkg clone — see docs/ops/build.md; a configure without it fails with a pointer there. A handful of dependencies are still fetched at configure time via FetchContent/CPM into OloEngine/vendor/ (never edit that directory — a CMake reconfigure wipes it). CMake also creates the build/ directory with the generated solution files.

Building & running

CMake presets (CMakePresets.json):

PresetGenerator / build dirPurpose
msvcVisual Studio 18 2026 → build/Primary, default build dir
clangclNinja Multi-Config → build-clang/clang-cl warnings with the MSVC ABI
clangcl-asanNinja Multi-Config → build-clang/Adds AddressSanitizer
# Generate the Visual Studio solution
scripts\Win-GenerateProjectVS2026.bat        # or Win-GenerateProjectVS2022.bat

# Build a target
cmake --build build --target OloEditor       --config Debug --parallel
cmake --build build --target OloEngine-Tests --config Debug --parallel
cmake --build build --target OloRuntime      --config Debug --parallel
cmake --build build --target OloServer       --config Debug --parallel

# ClangCL (configure once, then build)
cmake --preset clangcl
cmake --build build-clang --target OloEngine-Tests --config Debug --parallel

Working directory matters. OloEditor, OloRuntime, and OloServer resolve assets, shaders, and Mono assemblies relative to OloEditor/. Always run them with cwd = OloEditor/. The VS Code tasks already do this.

VS Code users — predefined tasks in .vscode/tasks.json wrap all of the above: build-oloeditor-debug, run-oloeditor-debug, build-tests-debug, run-tests-debug, configure-clangcl, etc. Run them from the Command Palette (Ctrl+Shift+P → Tasks: Run Task).

CLion users — open the repo folder, let CLion configure the CMake project, then set the working directory of the OloEditor run configuration to the OloEditor/ folder.

The full per-OS build matrix (including Linux and WSL specifics) lives in docs/ops/build.md.

Features

Core engine

  • Entity-Component-System built on EnTT under a UUID-keyed Entity wrapper. The hottest update loops use EnTT owning groups for packed iteration.
  • Deterministic gameplay scheduler — per-tick systems declare their read/write data flow and the execution order is derived by topological sort, with optional worker-thread parallelism (audited per system) and a physics-shadow phase that steps the physics world alongside game-thread systems.
  • Multi-threaded asset system — async loading with hot-reload via filewatch; typed retrieval through AssetManager.
  • Dual scripting — C# via Mono (Windows) and Lua via Sol2 (all platforms), with generated bindings.
  • YAML serialization — scenes, entities, and prefabs. Trivial component (de)serialization, the ECS component tuple, save-game capture/restore lists, and add/remove handlers are all code-generated from the component definitions (see Tooling), so most new components round-trip with zero hand-written glue.
  • Save games — component-level snapshot/restore with a versioned binary archive format.
  • Custom memory management, threading, and task systems — custom allocators, RAII resources, a job/task scheduler, and async primitives.

Rendering

  • Modern OpenGL 4.6 with Direct State Access and SPIR-V shader compilation.
  • Stateless, layered, multi-threaded command queue (Molecular Matters' design) driven by a render-graph.
  • Physically-based rendering — metallic-roughness workflow, image-based lighting (IBL).
  • Deferred + forward paths with Hi-Z GPU occlusion culling and LOD.
  • Post-processing & advanced lighting — SSAO, SSR, SSGI, contact shadows, all shadow types (incl. PCSS), TAA, volumetric fog + god rays, screen-space decals, order-independent transparency, and FSR/CAS upscaling.
  • Unified 2D/3D pipeline — sprites and 3D meshes through one renderer.
  • Skeletal animation — Assimp-imported skeletons, animation graphs and state machines, additive layers, and humanoid retargeting; secondary motion via spring bones and cloth.
  • GPU-driven particles — compute-based particle simulation.
  • Real-time particle fluids — GPU Position-Based Fluids solver (compute spatial hash + density-constraint solve) with screen-space fluid rendering, deterministic CPU fallback, and two-way Jolt rigid-body coupling.

Physics

  • 3D — Jolt Physics for rigid bodies, joints, characters, and vehicles.
  • 2D — Box2D for 2D mechanics.
  • Custom collision layers for flexible filtering and detection.

Environmental & world systems

  • Procedural terrain generation with GPU-driven quadtree LOD, and optional virtual texturing — a feedback-driven page cache that moves the splat blend off the per-pixel path.
  • Water / FFT ocean rendering.
  • Weather & environment — wind, snow (with deformation), and precipitation systems.
  • Streaming volumes, light-probe volumes, fog volumes, and navigation-mesh bounds as scene-level authored data.

Gameplay, AI & narrative

  • AI — GOAP planner / action system and a perception system (sight / sound / awareness).
  • Navigation — Recast/Detour nav-mesh generation with nav agents.
  • Dialogue & quests, plus a localization system driving LocalizedTextComponent.
  • Cinematic sequencer — timeline-driven cutscenes.

Networking

  • Reliable UDP transport over GameNetworkingSockets, with encrypted transport (libsodium) and a protobuf wire format for snapshots / RPC. OloServer provides the headless dedicated-server target.

Audio & video

  • 3D spatial audio (miniaudio) with source/listener ECS components and a SoundGraph / MetaSounds-style audio-graph.
  • Video playback component.

UI system

  • ECS-based UI — Canvas, RectTransform, Panel, Text, Image, Button, Slider, Checkbox, Toggle, Progress Bar, Input Field, Dropdown, Scroll View, Grid Layout, WorldAnchor, and more.
  • Anchor layout — RectTransform-style anchoring, pivots, and auto-layout via grid containers; works in both 2D and 3D.
  • Editor integration — a "Create UI" menu, per-component property panels, and editor-time preview.
  • Full C# and Lua bindings for every UI component.

Editor & debugging

  • ImGui editor — scene hierarchy, content/asset browser with drag-and-drop, gizmos and transform tools, and real-time scene manipulation.
  • Play-in-editor — snapshot/restore around Play/Simulate.
  • Profiling — Tracy integration plus custom renderer profiler and GPU/CPU memory tracking.
  • Read-only MCP diagnostics server — inspect a live editor frame (screenshots, camera control, intermediate render targets, shader errors) from an external tool. See docs/guides/mcp-diagnostics-server.md.

Testing

The suite (OloEngine-Tests, GoogleTest) spans two independent axes:

  1. A renderer testing pyramid (L1–L11 plus plumbing / culling-LOD / shader-pipe / integration layers) covering pixels, GL state, and shader math — including screenshot-based visual-regression tests that gate GPU-equipped runs and skip cleanly when no OpenGL 4.6 context is present.
  2. A Functional / cross-subsystem axis ("Functional" tag) driving real Scene::OnUpdateRuntime ticks across Animation × Physics × Scripting × Networking × Audio × Asset × Nav × Save-game × Gameplay × AI seams.
# Run everything, or a single test
build\OloEngine\tests\Debug\OloEngine-Tests.exe
build\OloEngine\tests\Debug\OloEngine-Tests.exe --gtest_filter=SuiteName.TestName

The rationale, per-layer reference, classification rules, and anti-patterns live in docs/testing.md and docs/agent-rules/testing-architecture.md.

Continuous integration

Per-PR and per-push

These gate every change and run on GitHub-hosted runners.

WorkflowStatusCovers
WindowsWindowsMSVC build + full suite via CTest
SanitizersSanitizersASan (Windows/clang-cl), ASan+LSan / UBSan / TSan (Linux/Clang)
SonarCloud ScanSonarCloud ScanStatic analysis (see sonarqube-review-alignment.md)
Pre-commit checksPre-commit checksFormatting, YAML, test-catalogue classification

Scheduled

Cron times are UTC — GitHub's scheduler does not observe DST, so local times shift by an hour across the year. Every one of these is also workflow_dispatch-able from the Actions tab. Scheduled runs may start tens of minutes late under load.

WorkflowStatusCadence (UTC)Runs onCovers
GPU Conformance (AMD)GPU Conformance (AMD, self-hosted)nightly 00:47self-hosted Linux / AMD Navi 10The renderer's visual, golden and perf layers against a real GPU — they skip everywhere else. See self-hosted-gpu-runner.md
Cross-Vendor ConformanceCross-Vendor Conformancenightly 03:47GitHub-hosted WindowsL1/3/5/6/8 against Mesa llvmpipe (software)
Fuzz SmokeFuzz Smokenightly 04:13GitHub-hosted WindowsParser / deserializer fuzzing
Flaky repro (#281)Flaky repro (#281)nightly 07:00GitHub-hosted WindowsRepeat-runs a known-flaky test to hunt issue #281
Video (FFmpeg backend)Video (FFmpeg backend)weekly, Mon 06:00GitHub-hosted matrixOLO_VIDEO_FFMPEG=ON build + decode tests
reflection-status-checkreflection-status-checkmonthly, 1st 09:07GitHub-hosted LinuxReminder to re-check C++26 reflection toolchain support

Hardware-GPU vendor coverage is AMD-only. The goldens in assets/tests/golden/ are baselined on NVIDIA, but only ever from a developer machine — there is no NVIDIA or Intel CI. llvmpipe is a third software implementation, not a second vendor, so it catches spec-compliance drift rather than driver divergence.

Tooling

OloHeaderTool (tools/OloHeaderTool/) scans OloEngine/src/ and generates, at build time (via the GenerateBindings target):

  • C++ and C# scripting bindings from OLO_PROPERTY annotations.
  • The ECS AllComponents type list, the save-game capture/restore lists, the OnComponentAdded/OnComponentRemoved no-op handlers, and per-component scene serialize/deserialize blocks — all derived from the struct *Component definitions.

This collapses what used to be several hand-maintained, easy-to-forget touch-points into codegen: an all-trivial component now round-trips through scenes, save-games, and scripting with little or no hand-written glue. See CLAUDE.md for the exact contract when adding or changing a component.

Dependencies

Most dependencies come from the vcpkg.json manifest, pinned as a whole by its builtin-baseline registry commit and built into vcpkg's binary cache. The ones marked (in-tree) below are still fetched via FetchContent/CPM into OloEngine/vendor/, each for a specific reason documented in that file's header comment — briefly: sol2 and Lua are pinned to a tested pair, stb's port is ~2 years behind on an image decoder, ImGuizmo's port would drag in a second Dear ImGui, and the single-header / forked / generator-driven ones have no port at all. OpenUSD and FFmpeg have their own build paths (see CLAUDE.md).

Core — entt (ECS) · glm (math) · spdlog (logging) · yaml-cpp (serialization) · nlohmann/json (tools / IPC) · choc (utilities, in-tree) · atomic_queue (lock-free MPMC queue) · meshoptimizer

Rendering — glad (in-tree) · glfw · assimp · stb (in-tree) · zlib

Physics — Jolt Physics (3D) · box2d (2D)

Networking — GameNetworkingSockets · libsodium · protobuf

Navigation — recastnavigation

Audio — miniaudio

Scripting — sol2 (in-tree) · lua (in-tree) · Mono (C# runtime, manually integrated)

UI & editor — imgui (in-tree) · imguizmo (in-tree)

Development & profiling — tracy · googletest · filewatch (in-tree)

Code style & pre-commit hooks

We enforce a small set of formatting rules via pre-commit:

python -m pip install --user pre-commit
pre-commit install                 # enable the git hook (run in the repo root)
pre-commit run --all-files         # optional: apply across the whole repo

Included hooks: trailing-whitespace, end-of-file-fixer, and clang-format for C/C++ (uses the repo-root .clang-format). An .editorconfig mirrors the whitespace rules for editors. The hooks ignore vendor/, build/, and IDE metadata folders. A GitHub Action runs pre-commit on every push and PR.

Documentation

Everything under docs/ — start at docs/README.md for the full index. Highlights:

  • docs/ops/build.md — full Windows / Linux / WSL build matrix.
  • docs/testing.md — the canonical testing opinion doc.
  • docs/guides/ — subsystem how-tos (AI/GOAP, perception, terrain, UI, localization, cinematics, video, MCP diagnostics).
  • docs/adr/ — architecture decision records.
  • CLAUDE.md — contributor/agent playbook: build, test, and the component cross-binding contract.

Influences & references

  • Hazel — the foundation this engine started from (The Cherno's game-engine series).
  • Lumos — ideas for the OpenGL renderer and engine architecture.
  • Arc — ideas for the audio engine.
  • Molecular Matters Blog — the multi-threaded render command queue architecture.
Source 2 files
hooks/register.ts 108 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { analyze, type Finding, formatFindings, normalize, parseTaskNotification } from './analyze'
4
5// Tools whose text can carry a build or test log: a foreground run, or a log read back.
6const LOG_TOOLS = ['Bash', 'PowerShell', 'Read'] as const
7
8const FS_READ_LIMIT = 4 * 1024 * 1024
9const TAIL_BYTES = 3_000_000
10const REPEAT_WINDOW_MS = 10 * 60 * 1000
11
12// The same finding is reported once per window: re-reading a log to look at its errors
13// should not repeat the banner every time. Module state; a reload clears it, which costs
14// at most one repeat.
15const reported = new Map<string, number>()
16
17export const register: Register = on => {
18  on('tool.call', { tool: LOG_TOOLS }, async ($, e, next) => {
19    const ran = await next(e)
20    if (ran.deny !== undefined || typeof ran.text !== 'string' || ran.text.length === 0) {
21      return ran
22    }
23    const findings = await fresh($, analyze(normalize(ran.text, e.tool === 'Read')))
24    if (findings.length === 0) {
25      return ran
26    }
27    surface($, findings)
28    return { ...ran, context: [...(ran.context ?? []), formatFindings(findings)] }
29  })
30
31  // A backgrounded build or test run reports "completed (exit code 0)" in its
32  // notification whatever the log says. Read the log's tail and say what it says.
33  on('session.append', async ($, e, next) => {
34    const textBlocks = e.message.content.filter(
35      (b): b is { type: 'text'; text: string } => b.type === 'text' && typeof (b as { text?: unknown }).text === 'string',
36    )
37    const notification = parseTaskNotification(textBlocks.map(b => b.text).join('\n'))
38    if (notification === undefined || !notification.isShellCommand) {
39      return next(e)
40    }
41
42    const log = await readTail($, notification.outputFile)
43    if (log === undefined) {
44      return next(e)
45    }
46    const findings = await fresh($, analyze(normalize(log)))
47    if (findings.length === 0) {
48      return next(e)
49    }
50    surface($, findings)
51    const note = `${formatFindings(findings)}\n(read from the tail of ${notification.outputFile}; the task status above reflects the wrapper, not the build or test result)`
52    return next({ ...e, message: { ...e.message, content: [...e.message.content, { type: 'text', text: note }] } })
53  })
54}
55
56async function fresh($: EngineInterface, findings: Finding[]): Promise<Finding[]> {
57  const now = await $.clock.now()
58  return findings.filter(f => {
59    const last = reported.get(f.id)
60    if (last !== undefined && now - last < REPEAT_WINDOW_MS) {
61      return false
62    }
63    reported.set(f.id, now)
64    return true
65  })
66}
67
68function surface($: EngineInterface, findings: Finding[]): void {
69  const first = findings.find(f => f.severity === 'error')
70  if (first !== undefined) {
71    $.ui.toast(`olo-output-truth: ${first.message.split('\n')[0] ?? 'a failure'}`, { timeoutMs: 8000 })
72  }
73}
74
75/**
76 * A log's text: whole when `$.fs.read` takes it (up to 4 MiB), else its last few MB through
77 * this mod's scripts/tail.ps1 (`$.process.run` has no shell, and pwsh is always present
78 * here), else `tail` where one is on PATH.
79 */
80async function readTail($: EngineInterface, path: string): Promise<string | undefined> {
81  try {
82    const stat = await $.fs.stat(path)
83    if (stat.kind !== 'file') {
84      return undefined
85    }
86    if (stat.size <= FS_READ_LIMIT) {
87      return await $.fs.read(path)
88    }
89  } catch {
90    return undefined
91  }
92  const attempts: string[][] = [
93    ['pwsh', '-NoProfile', '-NonInteractive', '-File', `${$.plugin.root}/scripts/tail.ps1`, '-Path', path, '-Bytes', String(TAIL_BYTES)],
94    ['tail', '-c', String(TAIL_BYTES), path],
95  ]
96  for (const argv of attempts) {
97    try {
98      const r = await $.process.run(argv, { timeoutMs: 15_000 })
99      if (r.exitCode === 0) {
100        return r.stdout
101      }
102    } catch {
103      // Try the next way.
104    }
105  }
106  return undefined
107}
108
hooks/analyze.ts 281 lines
1// Pure analysis of build and test output. No engine calls here, so the tests can
2// exercise every rule without a session.
3//
4// Each rule exists because the failure it catches was hit on this repo and read as
5// success (see the memory notes named beside each rule).
6
7export type Severity = 'error' | 'warn'
8
9export type Finding = {
10  /** Stable identity, so the same finding is reported once and not on every re-read. */
11  id: string
12  severity: Severity
13  message: string
14}
15
16const MAX_LISTED = 12
17const MAX_LINE = 220
18
19/**
20 * Normalises tool output for line-anchored matching: drops NULs (the engine logger
21 * embeds them, which is why `grep` calls the test log binary) and ANSI colour sequences
22 * (gtest colours its markers with --gtest_color or on a terminal), folds CRLF, and for
23 * the Read tool strips its `cat -n` line-number gutter.
24 */
25export function normalize(text: string, isNumberedListing = false): string {
26  let t = text
27    .replace(/\u0000/g, '')
28    .replace(/\u001b\[[0-9;]*[A-Za-z]/g, '')
29    .replace(/\r\n?/g, '\n')
30  if (isNumberedListing) {
31    t = t.replace(/^ *\d+\t/gm, '')
32  }
33  return t
34}
35
36function clip(line: string): string {
37  const one = line.trim()
38  return one.length > MAX_LINE ? `${one.slice(0, MAX_LINE)}…` : one
39}
40
41function unique<T>(items: Iterable<T>): T[] {
42  return [...new Set(items)]
43}
44
45// ---------------------------------------------------------------- builds ---
46
47const NINJA_STOPPED = /^ninja: build stopped: (.*)$/m
48const NINJA_FAILED_STEP = /^FAILED: (.*)$/gm
49const MSBUILD_FAILED = /^\s*Build FAILED\.\s*$/m
50const MSBUILD_ERRORS = /^\s*([1-9]\d*) Error\(s\)\s*$/m
51const ERROR_LINE =
52  /(?:\berror\s+(?:C|LNK|MSB)\d{4}\b|: (?:fatal )?error:|^\s*(?:lld-link|ld\.lld|clang-cl|cl)(?:\.exe)?: (?:fatal )?error\b|\bundefined symbol\b|\bfatal error\b)/im
53
54/**
55 * A build failure that the surrounding command may report as success: build-lock.ps1
56 * propagates ninja's status (since #788), but a run piped into `tail` or `tee` reports
57 * the pipe's last command, and that is how "exit code 0" builds with errors were read
58 * as green here. Memory: build-lock-wrapper-swallows-ninja-exit-code.
59 */
60export function analyzeBuild(text: string): Finding[] {
61  const stopped = NINJA_STOPPED.exec(text)
62  const msbuild = MSBUILD_FAILED.test(text) || MSBUILD_ERRORS.test(text)
63  const failedSteps = [...text.matchAll(NINJA_FAILED_STEP)].map(m => m[1] ?? '')
64  if (!stopped && !msbuild && failedSteps.length === 0) {
65    return []
66  }
67
68  const errorLines = unique(
69    text
70      .split('\n')
71      .filter(line => ERROR_LINE.test(line))
72      .map(clip),
73  ).slice(0, 6)
74
75  const exitedZero = /\[exited with code 0\]|exit code 0\b/.test(text)
76  const headline = stopped
77    ? `ninja: build stopped: ${stopped[1]}`
78    : msbuild
79      ? 'MSBuild reported Build FAILED'
80      : `${failedSteps.length} ninja step(s) FAILED`
81
82  const lines = [
83    `BUILD FAILED (${headline}).`,
84    exitedZero
85      ? 'The exit code 0 reported for it is NOT evidence of success: build-lock.ps1 passes on the status of the LAST command in its -Command, so a pipe (`| tail`, `| tee`) or a statement after the build masks the build\'s status.'
86      : 'Read the log, not the exit status: a pipe (`| tail`, `| tee`) reports its last command\'s status, not the build\'s.',
87    errorLines.length > 0 ? `First errors:\n${errorLines.map(l => `  ${l}`).join('\n')}` : '',
88    failedSteps.length > 0 && errorLines.length === 0
89      ? `Failed steps:\n${unique(failedSteps.map(clip)).slice(0, 4).map(l => `  ${l}`).join('\n')}`
90      : '',
91  ].filter(Boolean)
92
93  return [
94    {
95      id: `build:${headline}:${errorLines[0] ?? failedSteps[0] ?? ''}`,
96      severity: 'error',
97      message: lines.join('\n'),
98    },
99  ]
100}
101
102// ----------------------------------------------------------------- gtest ---
103
104const GTEST_RUNNING = /^\[==========\] Running (\d+) tests? from (\d+) test suites?\.?\s*$/gm
105const GTEST_DONE = /^\[==========\] (\d+) tests? from (\d+) test suites? ran\./m
106const GTEST_FILTER = /^Note: Google Test filter = (.*)$/m
107const GTEST_RUN = /^\[ RUN      \] (\S+)/gm
108const GTEST_OK = /^\[       OK \] (\S+)/gm
109const GTEST_FAILED = /^\[  FAILED  \] ([^\s,]+\.[^\s,]+)/gm
110const GTEST_SKIPPED = /^\[  SKIPPED \] (\S+)/gm
111const CTEST_NONE = /^No tests were found!!!\s*$/m
112
113function names(text: string, re: RegExp): string[] {
114  return unique([...text.matchAll(re)].map(m => m[1] ?? ''))
115}
116
117/** Splits a log into one segment per `[==========] Running` header (sharded or repeated runs). */
118function gtestSegments(text: string): string[] {
119  const starts = [...text.matchAll(GTEST_RUNNING)].map(m => m.index ?? 0)
120  return starts.map((start, i) => text.slice(start, starts[i + 1] ?? text.length))
121}
122
123function listed(items: string[]): string {
124  const shown = items.slice(0, MAX_LISTED).join(', ')
125  return items.length > MAX_LISTED ? `${shown}, … (+${items.length - MAX_LISTED} more)` : shown
126}
127
128/**
129 * gtest / ctest output that looks fine and is not.
130 * Memories: new-test-file-needs-cmakelists-entry, attribution-filter-must-select-the-test,
131 * build-lock-wrapper-swallows-ninja-exit-code (the two-space `[  FAILED  ]` count trap),
132 * taskstop-leaves-bash-loop-children-running.
133 */
134export function analyzeTests(text: string): Finding[] {
135  const findings: Finding[] = []
136  const filter = GTEST_FILTER.exec(text)?.[1]?.trim()
137
138  if (CTEST_NONE.test(text)) {
139    findings.push({
140      id: 'ctest:none',
141      severity: 'error',
142      message:
143        'ctest found NO tests for this selection: nothing ran. Check the -R regex against `ctest -N`, and that the test is registered.',
144    })
145  }
146
147  // ctest's own summary (it prints no gtest header without -V).
148  const ctest = /^\s*\d+% tests passed, (\d+) tests? failed out of (\d+)/m.exec(text)
149  if (ctest !== null && Number(ctest[1]) > 0) {
150    const list = text.slice(text.indexOf('The following tests FAILED:'))
151    const failedTests = unique([...list.matchAll(/^\s*\d+ - (\S+) \(/gm)].map(m => m[1] ?? ''))
152    findings.push({
153      id: `ctest:failed:${failedTests.join(',') || ctest[1]}`,
154      severity: 'error',
155      message: `ctest: ${ctest[1]} of ${ctest[2]} test(s) FAILED${failedTests.length > 0 ? `: ${listed(failedTests)}` : ''}.`,
156    })
157  }
158
159  const segments = gtestSegments(text)
160  if (segments.length === 0) {
161    return findings
162  }
163
164  let zeroRuns = 0
165  const failed = new Set<string>()
166  const incomplete: string[] = []
167  const unfinished: string[] = []
168  let ran = 0
169  let skipped = 0
170
171  for (const segment of segments) {
172    const header = /\[==========\] Running (\d+) tests?/.exec(segment)
173    const declared = Number(header?.[1] ?? '0')
174    if (declared === 0) {
175      zeroRuns += 1
176      continue
177    }
178
179    const runs = names(segment, GTEST_RUN)
180    const oks = new Set(names(segment, GTEST_OK))
181    const fails = names(segment, GTEST_FAILED)
182    const skips = new Set(names(segment, GTEST_SKIPPED))
183    for (const name of fails) {
184      failed.add(name)
185    }
186    ran += runs.length
187    skipped += [...skips].filter(name => runs.includes(name)).length
188
189    // No final summary: the segment did not finish, whether or not its last test did.
190    if (!GTEST_DONE.test(segment)) {
191      const last = runs[runs.length - 1]
192      const settled = (name: string) => oks.has(name) || fails.includes(name) || skips.has(name)
193      if (last !== undefined && !settled(last)) {
194        incomplete.push(last)
195      } else {
196        unfinished.push(`${runs.length} of ${declared} declared test(s)${last !== undefined ? `, last ${last}` : ''}`)
197      }
198    }
199  }
200
201  if (zeroRuns > 0) {
202    findings.push({
203      id: `gtest:zero:${filter ?? ''}`,
204      severity: 'error',
205      message: [
206        `The gtest filter${filter ? ` \`${filter}\`` : ''} selected NO tests: nothing ran, so nothing passed.`,
207        'Check the suite name with `--gtest_list_tests` and the same filter (an evidence PNG name is not a suite name),',
208        'and that a new test .cpp is listed in OloEngine/tests/CMakeLists.txt (it lists every source; an unlisted file is never built).',
209      ].join(' '),
210    })
211  }
212
213  if (incomplete.length > 0) {
214    findings.push({
215      id: `gtest:incomplete:${incomplete.join(',')}`,
216      severity: 'error',
217      message:
218        `The log ends inside ${listed(incomplete)} with no result line and no final summary: the run is either still going or the process died there (crash, abort, timeout). ` +
219        'Treat it as NOT passed. If you stopped it, check for a surviving OloEngine-Tests.exe / bash loop from THIS worktree before rebuilding. (If this is only part of the log, read its end.)',
220    })
221  }
222
223  if (unfinished.length > 0) {
224    findings.push({
225      id: `gtest:unfinished:${unfinished.join(',')}`,
226      severity: 'error',
227      message:
228        `The run has no final \`[==========] … ran.\` summary after ${unfinished.join('; ')}: it stopped between tests (still going, or the process died). ` +
229        'Treat it as NOT passed. (If this is only part of the log, read its end.)',
230    })
231  }
232
233  const failedNames = [...failed]
234  if (failedNames.length > 0) {
235    findings.push({
236      id: `gtest:failed:${failedNames.join(',')}`,
237      severity: 'error',
238      message: `${failedNames.length} test(s) FAILED: ${listed(failedNames)}.`,
239    })
240  }
241
242  if (ran > 0 && skipped === ran) {
243    findings.push({
244      id: `gtest:all-skipped:${filter ?? ''}:${ran}`,
245      severity: 'warn',
246      message:
247        `All ${ran} selected test(s) SKIPPED: nothing was verified. Evidence tests skip without a GL 4.6 context and Vulkan device tests without a device; say so rather than reporting them as passing.`,
248    })
249  }
250
251  return findings
252}
253
254// ------------------------------------------------------------------ both ---
255
256export function analyze(text: string): Finding[] {
257  return [...analyzeBuild(text), ...analyzeTests(text)]
258}
259
260export function formatFindings(findings: readonly Finding[]): string {
261  const body = findings.map(f => `- ${f.severity === 'error' ? 'ERROR' : 'WARNING'}: ${f.message}`).join('\n')
262  return `olo-output-truth (a mod that reads build/test output for traps this repo has hit):\n${body}`
263}
264
265/** Pulls the output file and status out of a background task's notification text. */
266export function parseTaskNotification(
267  text: string,
268): { outputFile: string; summary: string; isShellCommand: boolean } | undefined {
269  if (!text.includes('<task-notification>')) {
270    return undefined
271  }
272  const outputFile = /<output-file>([^<]+)<\/output-file>/.exec(text)?.[1]?.trim()
273  if (!outputFile) {
274    return undefined
275  }
276  const summary = /<summary>([\s\S]*?)<\/summary>/.exec(text)?.[1]?.trim() ?? ''
277  // Only a shell command's log is worth reading: an agent's .output is its JSONL transcript.
278  const isShellCommand = /^Background command\b/.test(summary)
279  return { outputFile, summary, isShellCommand }
280}
281