From 8590f1afc60432919fb0a1a75127dccdc8d67525 Mon Sep 17 00:00:00 2001 From: Pascal Severin Date: Sat, 12 Sep 2026 12:39:45 +0200 Subject: [PATCH] [M0-TOOL-01] Public API manifest: laige-api-scanner + checked-in laige-api.json + CI drift check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single C++20 tool (tools/api/laige-api-scanner) scans the public headers (src//include for the PRD §10.1 modules) and emits the deterministic machine-readable manifest laige-api.json: every public symbol (class/struct/enum/function/method/constructor/destructor/ variable/alias/enumerator/macro) with fully qualified name, header, line, normalized signature, doxygen summary, @budget annotation, and @experimental flag. Public-only access; namespace detail excluded; out-of-line member definitions add no entries. The scanner is a doxygen-comment-driven text walker, deliberately narrow: unsupported constructs (block comments, raw strings, lambdas at declaration level, function-pointer parameter types, operator(), extern "C" blocks, compound typedefs, #define in class bodies) fail loudly (CORE-008); the full contract is the header comment of laige-api.cpp. - laige-api CMake target: canonical regeneration of the checked-in root laige-api.json (cmake --build build --target laige-api); the checked-in manifest (376 symbols from 10 headers, all laige-core) is committed and stays CI-checked. - --check mode: byte compare (exit 0) or a symbol-level diff added/removed/changed (exit 1, max 20 shown) parsed with laige::parseJson; exit 2 on any error. - CI: api-manifest job in ci-pull.yml/ci.yml regenerates the manifest on every PR/merge and fails on drift; CTest (tests/api) covers the fixture tree (exact manifest bytes), fresh/stale checks, the unsupported-construct failure, the root error, and the real-tree drift check in every P0 job. - Docs in the same change (CORE-006): docs/README.md index (DOC-001), building.md status bullet + reserved command, roadmap M0-TOOL-01 marked done with the decision record. - Fix (latent, from M0-CORE-08): isValidUtf8 in json.cpp is [[maybe_unused]] — the UTF-8 assert preconditions compile out under NDEBUG, which broke the Release build with -Werror=unused-function (CORE-010). Verified: all 22 CTest entries pass in build (Release), build-asan, build-tsan, build-clang, build-shared, build-clang-shared, and a fresh build-clang-scratch; regeneration is byte-identical. --- .github/workflows/ci-pull.yml | 20 + .github/workflows/ci.yml | 21 + CMakeLists.txt | 1 + docs/README.md | 64 + docs/getting-started/building.md | 11 + laige-api.json | 394 ++++++ roadmap/M0-foundations.md | 37 +- src/laige-core/json.cpp | 6 +- tests/CMakeLists.txt | 4 + tests/api/CMakeLists.txt | 237 ++++ tests/api/expect-api-result.cmake.in | 44 + tools/api/CMakeLists.txt | 30 + tools/api/laige-api.cpp | 1915 ++++++++++++++++++++++++++ 13 files changed, 2782 insertions(+), 2 deletions(-) create mode 100644 docs/README.md create mode 100644 laige-api.json create mode 100644 tests/api/CMakeLists.txt create mode 100644 tests/api/expect-api-result.cmake.in create mode 100644 tools/api/CMakeLists.txt create mode 100644 tools/api/laige-api.cpp diff --git a/.github/workflows/ci-pull.yml b/.github/workflows/ci-pull.yml index eeaa6d0..0692cd9 100644 --- a/.github/workflows/ci-pull.yml +++ b/.github/workflows/ci-pull.yml @@ -236,3 +236,23 @@ jobs: - uses: actions/checkout@v4 - name: Lint include graph + report dependency count run: python3 tools/laige-include-lint + + api-manifest: + # M0-TOOL-01: runs on every PR (no ci:* condition). The checked-in + # public API manifest (laige-api.json, PRD §9.4, NFR-13.1) must stay + # in sync with the public headers: this job regenerates it from the + # current headers with laige-api-scanner and fails on any drift, so + # adding a public symbol without regenerating the manifest fails CI. + # The CTest suite runs the same check against the real tree in every + # P0 job as well (tests/api, test `api-real-tree`). + name: Public API manifest drift + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Configure + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug + - name: Build scanner + run: cmake --build build --target laige-api-scanner -j + - name: Check manifest drift + run: ./build/bin/laige-api-scanner --root . --check laige-api.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccec0a5..80cd461 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,7 @@ # macos-arm64 macOS arm64, AppleClang (macos-15) # macos-intel macOS Intel, AppleClang (macos-14) # include-lint Include-graph lint + dependency count (M0-CI-03) +# api-manifest Public API manifest drift check (M0-TOOL-01) # # The include-lint job (M0-CI-03; NFR-8.11, NFR-8.13) is platform- # independent — it parses the #include edges of src/** (PRD §10.1 rules: @@ -248,3 +249,23 @@ jobs: - uses: actions/checkout@v4 - name: Lint include graph + report dependency count run: python3 tools/laige-include-lint + + api-manifest: + # M0-TOOL-01 (PRD §9.4, NFR-13.1): the checked-in public API manifest + # (laige-api.json) must stay in sync with the public headers. This + # job regenerates the manifest from the current headers with + # laige-api-scanner and fails on any drift, so adding a public symbol + # without regenerating the manifest fails CI. The CTest suite runs + # the same check against the real tree in every P0 job as well + # (tests/api, test `api-real-tree`). + name: Public API manifest drift + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Configure + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug + - name: Build scanner + run: cmake --build build --target laige-api-scanner -j + - name: Check manifest drift + run: ./build/bin/laige-api-scanner --root . --check laige-api.json diff --git a/CMakeLists.txt b/CMakeLists.txt index beda7b8..142b93f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -226,4 +226,5 @@ if(LAIGE_BUILD_TESTS) # library-only build does not need them). add_subdirectory(tools/fuzz) add_subdirectory(tools/bench) # M0-CORE-08: laige-bench + add_subdirectory(tools/api) # M0-TOOL-01: laige-api-scanner endif() diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2b7e6ee --- /dev/null +++ b/docs/README.md @@ -0,0 +1,64 @@ +# Laige documentation + +Documentation index and navigation (DOC-001). The engine is at **M0** +(foundations): `laige-core` is the only populated module, and the +sections below mark what exists and what is still to land. + +## Build & tools + +- [Building Laige](getting-started/building.md) — the source of truth for + the canonical build commands, build trees, options, compiler policy + (NFR-8.10), sanitizer builds (NFR-8.2), and the current M0 status. + Tool commands are reserved there: `laige-fuzz`, `laige-bench`, + `laige-detcheck`, the `laige-api` manifest target, and the + include-graph lint. + +## API contracts (per public header) + +- [Result / Status / error codes](api/errors.md) — `laige::Result`, + `laige::Status`, the stable `ErrorCode` registry (M0-CORE-01). +- [Structured logging](api/logging.md) — the `laige::log` facade, sinks, + rate limiting, crash handling (M0-CORE-02; AGENTS §14). +- [SimMath deterministic math](api/sim_math.md) — the op surface plus the + `fp32_pinned` and `fpx16_16` backends, NaN/Inf policy, pinned-math + flags (M0-CORE-03/04; ADR 0002). +- [Memory pools](api/pools.md) — `ArenaPool` and `Pool` with + generation-checked handles and `PoolStats` accounting (M0-CORE-05). +- [Bounded JSON](api/json.md) — `laige::JsonValue`, `parseJson`, + `serializeJson`, `JsonOptions` bounds (M0-CORE-07; ADR 0003). +- [Budget harness](api/budget_harness.md) — `Histogram`, `TimeIt`, + `budgetCheck`, the AGENTS §12 report format, and the `budgets.json` + schema (M0-CORE-08). +- [PRNG](api/prng.md) — `laige::Prng`: the splitmix64/LCG64 hybrid, + period, and determinism contract (M0-CORE-06). + +## Architecture decisions (ADRs) + +- [ADR index](decisions/README.md) — 0001 (name and license), 0002 + (deterministic math), 0003 (config JSON), 0004 (GoogleTest + vendoring). + +## Not yet written (honest status) + +- `concepts/` — architecture, coordinates (ARCH-008; lands as + `docs/concepts/coordinates.md` with M0-DOC-02 — until then the + coordinate system is documented in the `Vec2`/`Vec3` comments of + `src/laige-core/include/laige/sim_math.h`), lifecycle, threading. +- `guides/` — task-oriented usage (first game, profiling, determinism). +- `debugging/` — debug mode (AGENTS §15 lands in M3), logging in + production, troubleshooting. +- `benchmarks/` — method, baselines, and the regression policy + (the harness exists — `laige-bench`, M0-CORE-08 — but the recorded + baselines land with M1 workloads). +- `compatibility/` — platform/compilers/formats matrix (the P0 matrix + is in [building.md](getting-started/building.md) for now). +- Per-module API docs for the M1+ modules (`laige-sim`, `laige-render`, + `laige-assets`, `laige-net`, `laige-server`, `laige-script`, + `laige-editor`) — they land with their modules. + +## Related + +- [Roadmap index](../roadmap/README.md) — the M0/M1/... step plan; + [M0 foundations](../roadmap/M0-foundations.md) is the current + milestone. +- `AGENTS.md` — the engineering contract this documentation implements. diff --git a/docs/getting-started/building.md b/docs/getting-started/building.md index 9d83809..f5e5935 100644 --- a/docs/getting-started/building.md +++ b/docs/getting-started/building.md @@ -197,6 +197,17 @@ pinned set and the NaN/Inf policy): runs in every build tree — in the ASan tree it is instrumented and is the step's sanitizer gate (NFR-8.7; PRD §14: fuzz "every commit (bounded), nightly (long)"). +- `api-fixture-scan`, `api-check-fresh`, `api-check-stale`, + `api-unsupported-construct`, `api-root-error`, and `api-real-tree` + are the M0-TOOL-01 CTest entries (`tests/api`): the public API + manifest scanner (`tools/api/laige-api-scanner`) runs against a + synthetic fixture tree (the exact manifest bytes are asserted) and + against the real repository tree (`--check laige-api.json`), so a + public-header change that misses the manifest fails in every P0 job + — and in the dedicated `api-manifest` CI job, which regenerates the + manifest and fails on any drift (PRD §9.4, NFR-13.1). The manifest + contract (symbol kinds, doc association, exit codes, unsupported + constructs) is the header comment of `tools/api/laige-api.cpp`. - Every configure verifies the vendored dependency lock (`cmake/laige-deps-lock.cmake` against `deps.lock`); a tampered or unlisted file under `deps/` fails the configure loudly. GoogleTest is the diff --git a/laige-api.json b/laige-api.json new file mode 100644 index 0000000..f04d23c --- /dev/null +++ b/laige-api.json @@ -0,0 +1,394 @@ +{ + "version": 1, + "generatedBy": "laige-api", + "headers": [ + "src/laige-core/include/laige/budget_harness.h", + "src/laige-core/include/laige/core/version.h", + "src/laige-core/include/laige/errors.h", + "src/laige-core/include/laige/fpx16_16.h", + "src/laige-core/include/laige/json.h", + "src/laige-core/include/laige/logging.h", + "src/laige-core/include/laige/pools.h", + "src/laige-core/include/laige/prng.h", + "src/laige-core/include/laige/result.h", + "src/laige-core/include/laige/sim_math.h" + ], + "symbols": [ + {"name": "laige::HistogramStats", "kind": "struct", "header": "src/laige-core/include/laige/budget_harness.h", "line": 154, "signature": "struct HistogramStats", "summary": "Summary statistics over the samples currently stored in a Histogram (rolling window). When n == 0 the six statistics are NaN (check n; budgetCheck turns an empty histogram into a loud NO_SAMPLES failure).", "budget": null, "experimental": false}, + {"name": "laige::HistogramStats::n", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 155, "signature": "std::uint64_t n", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::HistogramStats::min", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 156, "signature": "double min", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::HistogramStats::mean", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 157, "signature": "double mean", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::HistogramStats::p50", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 158, "signature": "double p50", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::HistogramStats::p95", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 159, "signature": "double p95", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::HistogramStats::p99", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 160, "signature": "double p99", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::HistogramStats::max", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 161, "signature": "double max", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Histogram", "kind": "class", "header": "src/laige-core/include/laige/budget_harness.h", "line": 167, "signature": "class Histogram", "summary": "A fixed-capacity, allocation-free sample store (rolling window). See the header preamble for the full contract (window semantics, nearest-rank percentile definition, performance, threading).", "budget": null, "experimental": false}, + {"name": "laige::Histogram::Options", "kind": "struct", "header": "src/laige-core/include/laige/budget_harness.h", "line": 169, "signature": "struct Options", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Histogram::Options::capacity", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 173, "signature": "std::size_t capacity = 0", "summary": "Capacity: the maximum number of samples kept. 0 is legal: every record() is dropped and stats() is always empty (useful as a churn-only counter, and as the loud-failure state for budgetCheck).", "budget": null, "experimental": false}, + {"name": "laige::Histogram::Histogram", "kind": "constructor", "header": "src/laige-core/include/laige/budget_harness.h", "line": 178, "signature": "explicit Histogram(Options options) noexcept : capacity_(options.capacity), window_(options.capacity), scratch_(options.capacity), cursor_(0), count_(0), total_(0)", "summary": "Setup path: performs the two backing allocations (window + scratch sort buffer). O(capacity) time and space.", "budget": null, "experimental": false}, + {"name": "laige::Histogram::Histogram", "kind": "constructor", "header": "src/laige-core/include/laige/budget_harness.h", "line": 187, "signature": "Histogram(const Histogram&) = default", "summary": "Value semantics: copy is O(capacity) (deep, cold path), move O(1).", "budget": null, "experimental": false}, + {"name": "laige::Histogram::Histogram", "kind": "constructor", "header": "src/laige-core/include/laige/budget_harness.h", "line": 188, "signature": "Histogram(Histogram&&) = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Histogram::operator=", "kind": "method", "header": "src/laige-core/include/laige/budget_harness.h", "line": 189, "signature": "Histogram& operator=(const Histogram&) = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Histogram::operator=", "kind": "method", "header": "src/laige-core/include/laige/budget_harness.h", "line": 190, "signature": "Histogram& operator=(Histogram&&) = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Histogram::record", "kind": "method", "header": "src/laige-core/include/laige/budget_harness.h", "line": 196, "signature": "void record(double value) noexcept", "summary": "Record one sample (unit: whatever the caller measures — typically milliseconds from a TimeIt). O(1), no allocation, no lock, noexcept. When the window is full the oldest sample is dropped; totalRecorded() keeps counting, so truncation is observable.", "budget": null, "experimental": false}, + {"name": "laige::Histogram::reset", "kind": "method", "header": "src/laige-core/include/laige/budget_harness.h", "line": 206, "signature": "void reset() noexcept", "summary": "Drop every stored sample (count -> 0). totalRecorded() survives (since-construction churn; per-frame profilers diff it). Idempotent.", "budget": null, "experimental": false}, + {"name": "laige::Histogram::count", "kind": "method", "header": "src/laige-core/include/laige/budget_harness.h", "line": 212, "signature": "[[nodiscard]] std::uint64_t count() const noexcept", "summary": "Samples currently stored in the window (<= capacity).", "budget": null, "experimental": false}, + {"name": "laige::Histogram::totalRecorded", "kind": "method", "header": "src/laige-core/include/laige/budget_harness.h", "line": 215, "signature": "[[nodiscard]] std::uint64_t totalRecorded() const noexcept", "summary": "Samples recorded since construction, including dropped ones (churn).", "budget": null, "experimental": false}, + {"name": "laige::Histogram::stats", "kind": "method", "header": "src/laige-core/include/laige/budget_harness.h", "line": 221, "signature": "[[nodiscard]] HistogramStats stats() const", "summary": "Exact statistics over the stored window (nearest-rank percentiles — see the preamble). Cold path: O(n log n) time, no allocation (sorts the pre-allocated scratch buffer; logically const — scratch_ is a reusable work buffer, not state).", "budget": null, "experimental": false}, + {"name": "laige::TimeIt", "kind": "class", "header": "src/laige-core/include/laige/budget_harness.h", "line": 281, "signature": "class TimeIt", "summary": "A scope timer over std::chrono::steady_clock (monotonic — see the preamble). Milliseconds as a double. No allocation, no lock.", "budget": null, "experimental": false}, + {"name": "laige::TimeIt::TimeIt", "kind": "constructor", "header": "src/laige-core/include/laige/budget_harness.h", "line": 284, "signature": "TimeIt() noexcept : start_(std::chrono::steady_clock::now())", "summary": "Starts the scope now.", "budget": null, "experimental": false}, + {"name": "laige::TimeIt::reset", "kind": "method", "header": "src/laige-core/include/laige/budget_harness.h", "line": 287, "signature": "void reset() noexcept", "summary": "Restarts the scope (owner thread only).", "budget": null, "experimental": false}, + {"name": "laige::TimeIt::elapsedMs", "kind": "method", "header": "src/laige-core/include/laige/budget_harness.h", "line": 290, "signature": "[[nodiscard]] double elapsedMs() const noexcept", "summary": "Elapsed time in milliseconds since construction/reset.", "budget": null, "experimental": false}, + {"name": "laige::BudgetMetric", "kind": "enum", "header": "src/laige-core/include/laige/budget_harness.h", "line": 306, "signature": "enum class BudgetMetric : std::uint8_t", "summary": "The statistic of a Histogram that a budget entry checks.", "budget": null, "experimental": false}, + {"name": "laige::BudgetMetric::Mean", "kind": "enumerator", "header": "src/laige-core/include/laige/budget_harness.h", "line": 306, "signature": "Mean", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetMetric::Min", "kind": "enumerator", "header": "src/laige-core/include/laige/budget_harness.h", "line": 306, "signature": "Min", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetMetric::Max", "kind": "enumerator", "header": "src/laige-core/include/laige/budget_harness.h", "line": 306, "signature": "Max", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetMetric::P50", "kind": "enumerator", "header": "src/laige-core/include/laige/budget_harness.h", "line": 306, "signature": "P50", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetMetric::P95", "kind": "enumerator", "header": "src/laige-core/include/laige/budget_harness.h", "line": 306, "signature": "P95", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetMetric::P99", "kind": "enumerator", "header": "src/laige-core/include/laige/budget_harness.h", "line": 306, "signature": "P99", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetEntry", "kind": "struct", "header": "src/laige-core/include/laige/budget_harness.h", "line": 310, "signature": "struct BudgetEntry", "summary": "One named budget from budgets.json (PRD 8.1 row -> entry). See the preamble for the target/measured/unit/metric contract.", "budget": null, "experimental": false}, + {"name": "laige::BudgetEntry::name", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 311, "signature": "std::string name", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetEntry::metric", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 312, "signature": "BudgetMetric metric", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetEntry::unit", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 313, "signature": "std::string unit", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetEntry::target", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 314, "signature": "double target", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetEntry::measured", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 315, "signature": "double measured", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetEntry::workload", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 316, "signature": "std::string workload", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetTable", "kind": "class", "header": "src/laige-core/include/laige/budget_harness.h", "line": 322, "signature": "class BudgetTable", "summary": "The parsed budgets.json (schema v1). Immutable after loading; safe to read from any thread. Small by design (one row per PRD 8.1 budget): find() is a linear scan on the cold path (no hash map, PERF-006).", "budget": null, "experimental": false}, + {"name": "laige::BudgetTable::BudgetTable", "kind": "constructor", "header": "src/laige-core/include/laige/budget_harness.h", "line": 324, "signature": "BudgetTable() = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetTable::size", "kind": "method", "header": "src/laige-core/include/laige/budget_harness.h", "line": 326, "signature": "[[nodiscard]] std::size_t size() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetTable::entries", "kind": "method", "header": "src/laige-core/include/laige/budget_harness.h", "line": 329, "signature": "[[nodiscard]] const std::vector& entries() const noexcept", "summary": "The entries in file order.", "budget": null, "experimental": false}, + {"name": "laige::BudgetTable::find", "kind": "method", "header": "src/laige-core/include/laige/budget_harness.h", "line": 334, "signature": "[[nodiscard]] const BudgetEntry* find(std::string_view name) const noexcept", "summary": "The entry with the given name, or nullptr when absent.", "budget": null, "experimental": false}, + {"name": "laige::BudgetReportContext", "kind": "struct", "header": "src/laige-core/include/laige/budget_harness.h", "line": 349, "signature": "struct BudgetReportContext", "summary": "Caller-supplied context for the AGENTS 12 report fields the harness cannot know (machine/build facts). The caller (benchmark runner / operator) fills these; budgetCheck formats them verbatim into the report (diagnostic text, not engine state). Defaults to all-empty.", "budget": null, "experimental": false}, + {"name": "laige::BudgetReportContext::workload", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 350, "signature": "const char* workload = \"\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetReportContext::build", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 351, "signature": "const char* build = \"\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetReportContext::machine", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 352, "signature": "const char* machine = \"\"", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetReportContext::warmup", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 353, "signature": "std::uint32_t warmup = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::formatStatsLine", "kind": "function", "header": "src/laige-core/include/laige/budget_harness.h", "line": 362, "signature": "[[nodiscard]] std::string formatStatsLine(const HistogramStats& s)", "summary": "The stable one-line text form of a HistogramStats value: `stats: n= min= mean= p50= p95= p99= max=` (6 significant digits, locale-free; \"nan\" for an empty histogram). The report lines of budgetCheck and the laige-bench tool both use this, so the stats text has one source (LOG-001 stable fields). Cold path: allocates one string.", "budget": null, "experimental": false}, + {"name": "laige::BudgetCheckResult", "kind": "struct", "header": "src/laige-core/include/laige/budget_harness.h", "line": 366, "signature": "struct BudgetCheckResult", "summary": "The outcome of a budget check plus the formatted AGENTS 12 report (stable multi-line text; format in docs/api/budget_harness.md).", "budget": null, "experimental": false}, + {"name": "laige::BudgetCheckResult::passed", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 367, "signature": "bool passed", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetCheckResult::measured", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 368, "signature": "double measured", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetCheckResult::target", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 370, "signature": "double target", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetCheckResult::before", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 371, "signature": "double before", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::BudgetCheckResult::report", "kind": "variable", "header": "src/laige-core/include/laige/budget_harness.h", "line": 372, "signature": "std::string report", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::loadBudgets", "kind": "function", "header": "src/laige-core/include/laige/budget_harness.h", "line": 385, "signature": "[[nodiscard]] Result loadBudgets(std::string_view path)", "summary": "Loads and validates budgets.json (schema v1) from `path`.", "budget": null, "experimental": false}, + {"name": "laige::budgetCheck", "kind": "function", "header": "src/laige-core/include/laige/budget_harness.h", "line": 394, "signature": "[[nodiscard]] BudgetCheckResult budgetCheck(const BudgetEntry& entry, const Histogram& histogram, const BudgetReportContext& context = {})", "summary": "Checks `entry` against `histogram` (semantics in the preamble: NO_SAMPLES / target>0 at-most / target==0 hard zero). before/after are entry.measured / the current measurement (the AGENTS 12 before/after pair). Cold path: O(n log n) (the stats pass) plus report string building (allocates — reporting is never a hot path). Thread-safe on const inputs; the histogram must not be mutated concurrently (CONC-001 single-owner rule).", "budget": null, "experimental": false}, + {"name": "laige::core::kMajor", "kind": "variable", "header": "src/laige-core/include/laige/core/version.h", "line": 15, "signature": "inline constexpr int kMajor = 0", "summary": "Semver components of the laige-core module (0 = not yet released).", "budget": null, "experimental": false}, + {"name": "laige::core::kMinor", "kind": "variable", "header": "src/laige-core/include/laige/core/version.h", "line": 16, "signature": "inline constexpr int kMinor = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::core::kPatch", "kind": "variable", "header": "src/laige-core/include/laige/core/version.h", "line": 17, "signature": "inline constexpr int kPatch = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::core::versionString", "kind": "function", "header": "src/laige-core/include/laige/core/version.h", "line": 24, "signature": "[[nodiscard]] const char* versionString()", "summary": "\"MAJOR.MINOR.PATCH\", rendered once on first call.", "budget": null, "experimental": false}, + {"name": "laige::ErrorCode", "kind": "enum", "header": "src/laige-core/include/laige/errors.h", "line": 31, "signature": "enum class ErrorCode : std::uint32_t", "summary": "Stable error codes. The enumerator values are part of the stability contract (PRD §9.4): never renumber, never reuse.", "budget": null, "experimental": false}, + {"name": "laige::ErrorCode::Unknown", "kind": "enumerator", "header": "src/laige-core/include/laige/errors.h", "line": 32, "signature": "Unknown = 1", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ErrorCode::InvalidArgument", "kind": "enumerator", "header": "src/laige-core/include/laige/errors.h", "line": 33, "signature": "InvalidArgument = 2", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ErrorCode::MalformedInput", "kind": "enumerator", "header": "src/laige-core/include/laige/errors.h", "line": 34, "signature": "MalformedInput = 3", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ErrorCode::BudgetExhausted", "kind": "enumerator", "header": "src/laige-core/include/laige/errors.h", "line": 35, "signature": "BudgetExhausted = 4", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ErrorCode::IoError", "kind": "enumerator", "header": "src/laige-core/include/laige/errors.h", "line": 36, "signature": "IoError = 5", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ErrorEntry", "kind": "struct", "header": "src/laige-core/include/laige/errors.h", "line": 44, "signature": "struct ErrorEntry", "summary": "One registry entry per stable code. `text` is the pre-rendered NFR-13.3 line; it MUST be exactly \"codeId | whatFailed | why | fix | docAnchor\". The result_status suite cross-checks `text` against the other fields and re-parses it, so drift or a broken grammar fails the test run loudly (CORE-006: docs, tests, and code ship together).", "budget": null, "experimental": false}, + {"name": "laige::ErrorEntry::code", "kind": "variable", "header": "src/laige-core/include/laige/errors.h", "line": 45, "signature": "ErrorCode code", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ErrorEntry::codeId", "kind": "variable", "header": "src/laige-core/include/laige/errors.h", "line": 46, "signature": "const char* codeId", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ErrorEntry::whatFailed", "kind": "variable", "header": "src/laige-core/include/laige/errors.h", "line": 47, "signature": "const char* whatFailed", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ErrorEntry::why", "kind": "variable", "header": "src/laige-core/include/laige/errors.h", "line": 48, "signature": "const char* why", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ErrorEntry::fix", "kind": "variable", "header": "src/laige-core/include/laige/errors.h", "line": 49, "signature": "const char* fix", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ErrorEntry::docAnchor", "kind": "variable", "header": "src/laige-core/include/laige/errors.h", "line": 50, "signature": "const char* docAnchor", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ErrorEntry::text", "kind": "variable", "header": "src/laige-core/include/laige/errors.h", "line": 51, "signature": "const char* text", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::errorInfo", "kind": "function", "header": "src/laige-core/include/laige/errors.h", "line": 57, "signature": "[[nodiscard]] const ErrorEntry& errorInfo(ErrorCode code)", "summary": "The registry entry for `code`. Unregistered values — including 0, the no-error sentinel — map to the `unknown` entry. Complexity O(1); no allocation; thread-safe.", "budget": null, "experimental": false}, + {"name": "laige::errorName", "kind": "function", "header": "src/laige-core/include/laige/errors.h", "line": 61, "signature": "[[nodiscard]] const char* errorName(ErrorCode code)", "summary": "The stable identifier (grammar field 1) for `code`. Complexity O(1); no allocation; thread-safe.", "budget": null, "experimental": false}, + {"name": "laige::errorText", "kind": "function", "header": "src/laige-core/include/laige/errors.h", "line": 67, "signature": "[[nodiscard]] const char* errorText(ErrorCode code)", "summary": "The pre-rendered NFR-13.3 line for `code`: \"{codeId} | {whatFailed} | {why} | {fix} | {docAnchor}\". Complexity O(1); no allocation; thread-safe; safe to hand to the logging facade (LOG-002).", "budget": null, "experimental": false}, + {"name": "laige::fpx16_16", "kind": "class", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 97, "signature": "class fpx16_16", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::raw", "kind": "variable", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 101, "signature": "std::int32_t raw{}", "summary": "Q16.16 raw units: value = raw / 2^16. Public for inspection and serialization; treat as opaque outside the helpers below.", "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::kMinRaw", "kind": "variable", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 105, "signature": "static constexpr std::int32_t kMinRaw = std::numeric_limits::min()", "summary": "-2^16 = -32768.0 and +2^16 - 2^-16 = +32767.99998474.", "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::kMaxRaw", "kind": "variable", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 107, "signature": "static constexpr std::int32_t kMaxRaw = std::numeric_limits::max()", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::min", "kind": "method", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 110, "signature": "static constexpr fpx16_16 min() noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::max", "kind": "method", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 111, "signature": "static constexpr fpx16_16 max() noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::one", "kind": "method", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 112, "signature": "static constexpr fpx16_16 one() noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::operator<=>", "kind": "method", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 115, "signature": "constexpr auto operator<=>(const fpx16_16&) const noexcept = default", "summary": "Total order (no NaN: every pair of Q16.16 values is comparable).", "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::add", "kind": "method", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 121, "signature": "static constexpr fpx16_16 add(fpx16_16 a, fpx16_16 b) noexcept", "summary": "a + b, saturating. The exact sum |a.raw + b.raw| ≤ 2^32 fits int64_t (no overflow — CPP-004).", "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::sub", "kind": "method", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 126, "signature": "static constexpr fpx16_16 sub(fpx16_16 a, fpx16_16 b) noexcept", "summary": "a - b, saturating. The exact difference fits int64_t.", "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::mul", "kind": "method", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 132, "signature": "static constexpr fpx16_16 mul(fpx16_16 a, fpx16_16 b) noexcept", "summary": "a * b, round-to-nearest ties-to-even, saturating. The exact product |a.raw * b.raw| ≤ 2^62 fits int64_t.", "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::div", "kind": "method", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 138, "signature": "static fpx16_16 div(fpx16_16 a, fpx16_16 b) noexcept", "summary": "a / b, round-to-nearest ties-to-even, saturating. Division by zero is defined (never traps): x/0 → ±max() (sign of x), 0/0 → +0.", "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::negate", "kind": "method", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 158, "signature": "static constexpr fpx16_16 negate(fpx16_16 a) noexcept", "summary": "-a, saturating: negate(min()) == max() because -(-2^16) is not representable. Equivalent to SimMath::sub(Scalar{}, a).", "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::sqrt", "kind": "method", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 165, "signature": "static fpx16_16 sqrt(fpx16_16 a) noexcept", "summary": "sqrt(a), round-to-nearest (no exact ties exist — see the header preamble), saturating. sqrt of a negative value is defined as +0 (the fixed-point analogue of the IEEE domain error, with no NaN).", "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::fromInt32", "kind": "method", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 182, "signature": "static constexpr fpx16_16 fromInt32(std::int32_t v) noexcept", "summary": "int32_t → Q16.16, exact for |v| ≤ 32768 (v = -32768 maps exactly to min()); saturates outside the Q16.16 range (defined — no UB for any input, CPP-004).", "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::toInt32", "kind": "method", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 191, "signature": "static constexpr std::int32_t toInt32(fpx16_16 x) noexcept", "summary": "Q16.16 → int32_t, round-to-nearest ties-to-even. The result lies in [-32768, 32768] and always fits int32_t.", "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::toFloat", "kind": "method", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 197, "signature": "static float toFloat(fpx16_16 x) noexcept", "summary": "Q16.16 → float: one rounding (raw → nearest binary32), then the exact power-of-two scale 2^-16 (exact exponent adjustment).", "budget": null, "experimental": false}, + {"name": "laige::fpx16_16::fromFloat", "kind": "method", "header": "src/laige-core/include/laige/fpx16_16.h", "line": 203, "signature": "static fpx16_16 fromFloat(float v) noexcept", "summary": "float → Q16.16: NaN → +0 (defined); ±inf and |v| ≥ 32768 saturate; otherwise one rounding of the exact scale v * 2^16, ties-to-even.", "budget": null, "experimental": false}, + {"name": "laige::JsonKind", "kind": "enum", "header": "src/laige-core/include/laige/json.h", "line": 150, "signature": "enum class JsonKind : std::uint8_t", "summary": "The six JSON value kinds (RFC 8259).", "budget": null, "experimental": false}, + {"name": "laige::JsonKind::Null", "kind": "enumerator", "header": "src/laige-core/include/laige/json.h", "line": 151, "signature": "Null", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonKind::Bool", "kind": "enumerator", "header": "src/laige-core/include/laige/json.h", "line": 152, "signature": "Bool", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonKind::Number", "kind": "enumerator", "header": "src/laige-core/include/laige/json.h", "line": 153, "signature": "Number", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonKind::String", "kind": "enumerator", "header": "src/laige-core/include/laige/json.h", "line": 154, "signature": "String", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonKind::Array", "kind": "enumerator", "header": "src/laige-core/include/laige/json.h", "line": 155, "signature": "Array", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonKind::Object", "kind": "enumerator", "header": "src/laige-core/include/laige/json.h", "line": 156, "signature": "Object", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonOptions", "kind": "struct", "header": "src/laige-core/include/laige/json.h", "line": 163, "signature": "struct JsonOptions", "summary": "Parse bounds (ADR 0003). The defaults are the documented ones; engine code may tighten (or, in principle, loosen) them per parse call. maxDepth <= 0 rejects every container; maxDocumentBytes is a bound on the raw input bytes.", "budget": null, "experimental": false}, + {"name": "laige::JsonOptions::maxDocumentBytes", "kind": "variable", "header": "src/laige-core/include/laige/json.h", "line": 164, "signature": "std::size_t maxDocumentBytes = static_cast(1u) << 20", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonOptions::maxDepth", "kind": "variable", "header": "src/laige-core/include/laige/json.h", "line": 165, "signature": "int maxDepth = 32", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue", "kind": "class", "header": "src/laige-core/include/laige/json.h", "line": 171, "signature": "class JsonValue", "summary": "A parsed (or hand-built) JSON document. See the preamble for the full contract: grammar, bounds, number semantics, ownership, threading, equality, performance, and error behavior.", "budget": null, "experimental": false}, + {"name": "laige::JsonValue::JsonValue", "kind": "constructor", "header": "src/laige-core/include/laige/json.h", "line": 174, "signature": "JsonValue() noexcept = default", "summary": "The Null value.", "budget": null, "experimental": false}, + {"name": "laige::JsonValue::JsonValue", "kind": "constructor", "header": "src/laige-core/include/laige/json.h", "line": 181, "signature": "JsonValue(const JsonValue& other) noexcept", "summary": "Value semantics (preamble): copy is deep (O(size), allocates); move is O(1) and leaves the moved-from value Null. Copy assignment and move assignment release this value's old payload first, so the \"owns exactly the payload its kind names\" invariant holds across assignment, not just across kind-changing mutation.", "budget": null, "experimental": false}, + {"name": "laige::JsonValue::operator=", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 182, "signature": "JsonValue& operator=(const JsonValue& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::JsonValue", "kind": "constructor", "header": "src/laige-core/include/laige/json.h", "line": 183, "signature": "JsonValue(JsonValue&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::operator=", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 184, "signature": "JsonValue& operator=(JsonValue&& other) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::fromBool", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 188, "signature": "[[nodiscard]] static JsonValue fromBool(bool value) noexcept", "summary": "Factories (the engine builds with -fno-exceptions: a failed allocation terminates the process, it never throws).", "budget": null, "experimental": false}, + {"name": "laige::JsonValue::fromNumber", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 189, "signature": "[[nodiscard]] static JsonValue fromNumber(double value) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::fromString", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 190, "signature": "[[nodiscard]] static JsonValue fromString(std::string_view value) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::makeArray", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 191, "signature": "[[nodiscard]] static JsonValue makeArray() noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::makeObject", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 192, "signature": "[[nodiscard]] static JsonValue makeObject() noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::kind", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 195, "signature": "[[nodiscard]] JsonKind kind() const noexcept", "summary": "Kind queries.", "budget": null, "experimental": false}, + {"name": "laige::JsonValue::isNull", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 196, "signature": "[[nodiscard]] bool isNull() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::isBool", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 197, "signature": "[[nodiscard]] bool isBool() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::isNumber", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 198, "signature": "[[nodiscard]] bool isNumber() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::isString", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 199, "signature": "[[nodiscard]] bool isString() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::isArray", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 200, "signature": "[[nodiscard]] bool isArray() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::isObject", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 201, "signature": "[[nodiscard]] bool isObject() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::asBool", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 205, "signature": "[[nodiscard]] bool asBool() const noexcept", "summary": "Kind accessors. Precondition: the matching kind (debug assert; documented undefined behavior in release).", "budget": null, "experimental": false}, + {"name": "laige::JsonValue::asNumber", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 206, "signature": "[[nodiscard]] double asNumber() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::asString", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 207, "signature": "[[nodiscard]] std::string_view asString() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::asArray", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 208, "signature": "[[nodiscard]] const std::vector& asArray() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::asObject", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 209, "signature": "[[nodiscard]] const std::vector>& asObject() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::findMember", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 214, "signature": "[[nodiscard]] const JsonValue* findMember(std::string_view name) const noexcept", "summary": "Null-safe object lookups (total on any value): nullptr / false when this is not an object or the member is absent.", "budget": null, "experimental": false}, + {"name": "laige::JsonValue::hasMember", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 215, "signature": "[[nodiscard]] bool hasMember(std::string_view name) const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::setNull", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 222, "signature": "void setNull() noexcept", "summary": "Mutations. The setX() forms make this the given kind/value, releasing the old payload first. append()/setMember() require the matching container kind (debug assert; documented undefined behavior in release). setMember() replaces an existing member in place (position preserved) or appends it.", "budget": null, "experimental": false}, + {"name": "laige::JsonValue::setBool", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 223, "signature": "void setBool(bool value) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::setNumber", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 224, "signature": "void setNumber(double value) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::setString", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 225, "signature": "void setString(std::string_view value) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::append", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 226, "signature": "void append(JsonValue element)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::setMember", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 227, "signature": "void setMember(std::string_view name, JsonValue value)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::JsonValue::operator==", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 231, "signature": "[[nodiscard]] bool operator==(const JsonValue& other) const noexcept", "summary": "Deep structural equality (see the preamble: objects order-insensitive, arrays order-sensitive, NaN != NaN).", "budget": null, "experimental": false}, + {"name": "laige::JsonValue::operator!=", "kind": "method", "header": "src/laige-core/include/laige/json.h", "line": 232, "signature": "[[nodiscard]] bool operator!=(const JsonValue& other) const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::parseJson", "kind": "function", "header": "src/laige-core/include/laige/json.h", "line": 255, "signature": "[[nodiscard]] Result parseJson(std::string_view input, JsonOptions options = {})", "summary": "Parses exactly one JSON document from `input` (the whole view must be consumed; trailing non-whitespace is MalformedInput). Bounded by `options` (defaults: 1 MiB, depth 32 — see the preamble). Every failure is ErrorCode::MalformedInput; a failed parse produces no value.", "budget": null, "experimental": false}, + {"name": "laige::serializeJson", "kind": "function", "header": "src/laige-core/include/laige/json.h", "line": 267, "signature": "[[nodiscard]] std::string serializeJson(const JsonValue& value)", "summary": "Serializes `value` to canonical compact JSON (no insignificant whitespace): strings ASCII-safe (\\uXXXX for control characters and every codepoint above 0x7F; the two-character escapes for the six printable ones), numbers shortest-round-trip decimal (see the preamble), objects and arrays in stored order. Precondition: no Number holding NaN or +/-inf anywhere in the value (debug assert; documented undefined behavior in release). Cold path: allocates one output string plus recursive calls per nesting level.", "budget": null, "experimental": false}, + {"name": "laige::log::Severity", "kind": "enum", "header": "src/laige-core/include/laige/logging.h", "line": 93, "signature": "enum class Severity : std::uint8_t", "summary": "Event severity. Contract per level (AGENTS.md §14): Trace very high-volume diagnostic detail; disabled by default Debug developer-facing state Info low-volume lifecycle / significant state transitions Warn degraded behavior the engine recovered from Error an operation or subsystem failed Fatal continued execution is unsafe: the facade records the event, flushes, and terminates the process (std::abort) — controlled termination after preserving diagnostics", "budget": null, "experimental": false}, + {"name": "laige::log::Severity::Trace", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 94, "signature": "Trace = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Severity::Debug", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 95, "signature": "Debug = 1", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Severity::Info", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 96, "signature": "Info = 2", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Severity::Warn", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 97, "signature": "Warn = 3", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Severity::Error", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 98, "signature": "Error = 4", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Severity::Fatal", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 99, "signature": "Fatal = 5", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Level", "kind": "enum", "header": "src/laige-core/include/laige/logging.h", "line": 106, "signature": "enum class Level : std::uint8_t", "summary": "Minimum-severity filter, used either logger-wide (global minimum) or for one subsystem (per-subsystem scope, FR-12.2). An event is recorded only when severity >= the applicable level. Off disables everything (the cheap switch for release/server profiles).", "budget": null, "experimental": false}, + {"name": "laige::log::Level::Trace", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 107, "signature": "Trace = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Level::Debug", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 108, "signature": "Debug = 1", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Level::Info", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 109, "signature": "Info = 2", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Level::Warn", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 110, "signature": "Warn = 3", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Level::Error", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 111, "signature": "Error = 4", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Level::Fatal", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 112, "signature": "Fatal = 5", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Level::Off", "kind": "enumerator", "header": "src/laige-core/include/laige/logging.h", "line": 113, "signature": "Off = 6", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::severityName", "kind": "function", "header": "src/laige-core/include/laige/logging.h", "line": 118, "signature": "[[nodiscard]] inline const char* severityName(Severity severity) noexcept", "summary": "The stable lowercase token for a severity, as rendered in log lines. O(1), no allocation, thread-safe.", "budget": null, "experimental": false}, + {"name": "laige::log::kRateLimitedEvent", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 132, "signature": "inline constexpr const char* kRateLimitedEvent = \"rate_limited\"", "summary": "The stable event name of the rate-limit summary (LOG-001: machine searchable). A summary reports suppressed repeats of another event.", "budget": null, "experimental": false}, + {"name": "laige::log::Field", "kind": "struct", "header": "src/laige-core/include/laige/logging.h", "line": 145, "signature": "struct Field", "summary": "One structured key/value pair of a log event.", "budget": null, "experimental": false}, + {"name": "laige::log::Field::name", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 146, "signature": "std::string_view name", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Field::value", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 147, "signature": "std::string value", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::field", "kind": "function", "header": "src/laige-core/include/laige/logging.h", "line": 204, "signature": "template Field field(std::string_view name, const T& value)", "summary": "Build a log field from a scalar value (see Field).", "budget": null, "experimental": false}, + {"name": "laige::log::LogRecord", "kind": "struct", "header": "src/laige-core/include/laige/logging.h", "line": 231, "signature": "struct LogRecord", "summary": "One recorded log event — what a Sink receives.", "budget": null, "experimental": false}, + {"name": "laige::log::LogRecord::timestamp", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 236, "signature": "std::chrono::system_clock::time_point timestamp", "summary": "One documented clock (AGENTS §14): std::chrono::system_clock, rendered by the sinks in UTC as \"YYYY-MM-DDTHH:MM:SS.ffffffZ\" (RFC 3339). Diagnostics only — never part of authoritative state (ARCH-009).", "budget": null, "experimental": false}, + {"name": "laige::log::LogRecord::severity", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 237, "signature": "Severity severity", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::LogRecord::subsystem", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 238, "signature": "std::string_view subsystem", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::LogRecord::event", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 239, "signature": "std::string_view event", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::LogRecord::message", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 240, "signature": "std::string_view message", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::LogRecord::fields", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 241, "signature": "std::span fields", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::LogRecord::threadId", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 244, "signature": "std::uint32_t threadId", "summary": "Emitting-thread identity (std::hash of std::thread::id; the \"thread or job identity\" field of AGENTS §14).", "budget": null, "experimental": false}, + {"name": "laige::log::Sink", "kind": "class", "header": "src/laige-core/include/laige/logging.h", "line": 259, "signature": "class Sink", "summary": "A replaceable logging backend (FR-12.2: sink-swappable).", "budget": null, "experimental": false}, + {"name": "laige::log::Sink::~Sink", "kind": "destructor", "header": "src/laige-core/include/laige/logging.h", "line": 261, "signature": "virtual ~Sink() = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Sink::emit", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 262, "signature": "virtual void emit(const LogRecord& record) = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Sink::flush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 263, "signature": "virtual void flush() = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::ConsoleSink", "kind": "class", "header": "src/laige-core/include/laige/logging.h", "line": 273, "signature": "class ConsoleSink : public Sink", "summary": "Sink writing one line per event to a std::FILE stream (default: stderr). The sink does NOT own the stream — it never fopens or fcloses it (a ConsoleSink(stderr) must outlive the process and the process must keep stderr usable for crash diagnostics).", "budget": null, "experimental": false}, + {"name": "laige::log::ConsoleSink::ConsoleSink", "kind": "constructor", "header": "src/laige-core/include/laige/logging.h", "line": 275, "signature": "explicit ConsoleSink(std::FILE* stream) : stream_(stream)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::ConsoleSink::~ConsoleSink", "kind": "destructor", "header": "src/laige-core/include/laige/logging.h", "line": 276, "signature": "~ConsoleSink() override", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::ConsoleSink::emit", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 278, "signature": "void emit(const LogRecord& record) override", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::ConsoleSink::flush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 279, "signature": "void flush() override", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::ConsoleSink::failedWrites", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 282, "signature": "[[nodiscard]] std::uint64_t failedWrites() const noexcept", "summary": "Records whose line could not be written (0 = healthy).", "budget": null, "experimental": false}, + {"name": "laige::log::FileSink", "kind": "class", "header": "src/laige-core/include/laige/logging.h", "line": 298, "signature": "class FileSink : public Sink", "summary": "Sink appending one line per event to a file.", "budget": null, "experimental": false}, + {"name": "laige::log::FileSink::create", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 307, "signature": "[[nodiscard]] static laige::Result> create(std::string path)", "summary": "Opens `path` in binary append mode (platform-stable on-disk format: LF-terminated lines, no Windows text-mode CRLF translation) with plain-`fopen` sharing semantics: the file may be opened read-only concurrently — even by the same process — on every platform, including Windows (where the secure `fopen_s` would deny even that). Never throws (NFR-8.10): a failed open is a Status carrying ErrorCode::IoError.", "budget": null, "experimental": false}, + {"name": "laige::log::FileSink::~FileSink", "kind": "destructor", "header": "src/laige-core/include/laige/logging.h", "line": 310, "signature": "~FileSink() override", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::FileSink::emit", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 312, "signature": "void emit(const LogRecord& record) override", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::FileSink::flush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 313, "signature": "void flush() override", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::FileSink::failedWrites", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 316, "signature": "[[nodiscard]] std::uint64_t failedWrites() const noexcept", "summary": "Records whose line could not be written (0 = healthy).", "budget": null, "experimental": false}, + {"name": "laige::log::FileSink::path", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 319, "signature": "[[nodiscard]] std::string_view path() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::LoggerOptions", "kind": "struct", "header": "src/laige-core/include/laige/logging.h", "line": 344, "signature": "struct LoggerOptions", "summary": "Init-phase configuration for Logger::init().", "budget": null, "experimental": false}, + {"name": "laige::log::LoggerOptions::sink", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 347, "signature": "std::unique_ptr sink = nullptr", "summary": "The sink to use; null → a ConsoleSink on stderr. The logger takes ownership (unique_ptr).", "budget": null, "experimental": false}, + {"name": "laige::log::LoggerOptions::globalMinimum", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 350, "signature": "Level globalMinimum = Level::Debug", "summary": "Global minimum severity, checked before the per-subsystem level — one atomic load, the cheap first gate.", "budget": null, "experimental": false}, + {"name": "laige::log::LoggerOptions::defaultSubsystemLevel", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 353, "signature": "Level defaultSubsystemLevel = Level::Debug", "summary": "Level applied to subsystems not registered via setSubsystemLevel(). Trace is disabled by default (AGENTS §14).", "budget": null, "experimental": false}, + {"name": "laige::log::LoggerOptions::rateLimiting", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 356, "signature": "bool rateLimiting = true", "summary": "LOG-004: repeated failures are rate-limited per (subsystem, event, severity) for Warn/Error/Fatal.", "budget": null, "experimental": false}, + {"name": "laige::log::LoggerOptions::rateWindow", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 361, "signature": "std::chrono::milliseconds rateWindow = std::chrono::milliseconds(1000)", "summary": "Rate window: at most one event per key per window reaches the sink; the rest are counted and reported in a `rate_limited` summary event when the next event for the key lands after the window (and at shutdown for pending counts).", "budget": null, "experimental": false}, + {"name": "laige::log::LoggerOptions::ClockFn", "kind": "alias", "header": "src/laige-core/include/laige/logging.h", "line": 365, "signature": "using ClockFn = std::chrono::system_clock::time_point (*)()", "summary": "Clock for timestamps and rate decisions; null → std::chrono::system_clock::now(). Called only for enabled events (never on the disabled path); injectable for tests.", "budget": null, "experimental": false}, + {"name": "laige::log::LoggerOptions::clock", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 366, "signature": "ClockFn clock = nullptr", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger", "kind": "class", "header": "src/laige-core/include/laige/logging.h", "line": 373, "signature": "class Logger", "summary": "The one logging facade (AGENTS §14): a process-lifetime Meyers singleton. See the header top for ownership, threading, and performance contracts; the full API contract is in docs/api/logging.md.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::instance", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 375, "signature": "[[nodiscard]] static Logger& instance()", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::init", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 385, "signature": "[[nodiscard]] laige::Status init(LoggerOptions options)", "summary": "Init-phase configuration (MUST NOT run concurrently with logging from other threads). Replaces the current sink (flushed first) and resets subsystem levels, rate state, and the retired flag; a previously installed crash handler is re-registered by a later installCrashHandling() call. Always succeeds: a sink that can fail is created via FileSink::create() before init (hand its sink over with Result::takeValue()). Takes options by value and consumes the sink ownership — call with an rvalue.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::setSubsystemLevel", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 389, "signature": "void setSubsystemLevel(std::string_view subsystem, Level level)", "summary": "Per-subsystem level filter (FR-12.2 per-subsystem scopes). Init-phase API. The subsystem name is copied into the facade.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::subsystemLevel", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 393, "signature": "[[nodiscard]] Level subsystemLevel(std::string_view subsystem) const", "summary": "The effective level for `subsystem` (its registered level, or defaultSubsystemLevel_ when unregistered). Init-phase API.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::setGlobalMinimum", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 395, "signature": "void setGlobalMinimum(Level level)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::globalMinimum", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 396, "signature": "[[nodiscard]] Level globalMinimum() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::enabled", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 402, "signature": "[[nodiscard]] bool enabled(Severity severity, std::string_view subsystem) const", "summary": "Cheap gate behind LAIGE_LOG_*: true only when an event of `severity` from `subsystem` will be recorded. Cost: one atomic load, plus (only if that passes) one mutex section over a small linear scan — no allocation, no formatting (LOG-003).", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::emit", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 410, "signature": "template void emit(Severity severity, std::string_view subsystem, std::string_view event, std::string_view message, Fields&&... fields)", "summary": "Record an enabled event. Fields are moved into the record; the subsystem/event/message string_views must outlive the call. Direct calls evaluate their arguments eagerly — prefer the LAIGE_LOG_* macros (lazy). Fatal events flush and then terminate the process (AGENTS §14 controlled termination).", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::flush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 419, "signature": "void flush()", "summary": "Flush the sink (LOG-007).", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::shutdown", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 424, "signature": "void shutdown()", "summary": "Controlled shutdown (CONC-006, idempotent): drain pending rate-limit summaries, flush the sink, and retire the facade — log calls after shutdown are discarded (no sink calls).", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::installCrashHandling", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 433, "signature": "[[nodiscard]] laige::Status installCrashHandling()", "summary": "Install crash handlers (LOG-007): SIGSEGV/SIGABRT/SIGBUS/SIGFPE/ SIGILL on POSIX (sigaction, one-shot SA_RESETHAND), a vectored SEH filter on Windows. The handler writes a raw notice to stderr (write(2): no stdio lock, no allocation), flushes the sink (try_lock, allocation-free), and lets the default crash handling continue (core dump / debugger / abort). Init-phase API; idempotent.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::sink", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 436, "signature": "[[nodiscard]] const Sink* sink() const noexcept", "summary": "The current sink (diagnostics, DBG-008); never null.", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::crashFlush", "kind": "method", "header": "src/laige-core/include/laige/logging.h", "line": 440, "signature": "void crashFlush() const", "summary": "Flush from a crash handler: no facade lock (the signal may have interrupted a dispatch holding it), no allocation (LOG-007).", "budget": null, "experimental": false}, + {"name": "laige::log::Logger::SubsystemEntry::name", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 446, "signature": "std::string name", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::SubsystemEntry::level", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 447, "signature": "Level level", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::subsystem", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 455, "signature": "std::string subsystem", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::event", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 456, "signature": "std::string event", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::severity", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 457, "signature": "Severity severity", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::everEmitted", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 458, "signature": "bool everEmitted = false", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::lastEmit", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 459, "signature": "std::chrono::system_clock::time_point lastEmit{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::log::Logger::RateEntry::suppressed", "kind": "variable", "header": "src/laige-core/include/laige/logging.h", "line": 460, "signature": "std::uint64_t suppressed = 0", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 524, "signature": "#define LAIGE_LOG(severity, subsystem, event, message, ...)", "summary": "The public logging macros (AGENTS §14 example shape)", "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_TRACE", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 528, "signature": "#define LAIGE_LOG_TRACE(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_DEBUG", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 531, "signature": "#define LAIGE_LOG_DEBUG(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_INFO", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 534, "signature": "#define LAIGE_LOG_INFO(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_WARN", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 537, "signature": "#define LAIGE_LOG_WARN(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_ERROR", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 540, "signature": "#define LAIGE_LOG_ERROR(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "LAIGE_LOG_FATAL", "kind": "macro", "header": "src/laige-core/include/laige/logging.h", "line": 543, "signature": "#define LAIGE_LOG_FATAL(subsystem, event, message, ...)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PoolStats", "kind": "struct", "header": "src/laige-core/include/laige/pools.h", "line": 109, "signature": "struct PoolStats", "summary": "One pool's accounting snapshot (PRD §10.4, FR-11.4, G-R4). A plain value the M1 profiler aggregates; there is no registration (CORE-004).", "budget": null, "experimental": false}, + {"name": "laige::PoolStats::capacity", "kind": "variable", "header": "src/laige-core/include/laige/pools.h", "line": 110, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PoolStats::inUse", "kind": "variable", "header": "src/laige-core/include/laige/pools.h", "line": 111, "signature": "std::uint32_t inUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PoolStats::peakInUse", "kind": "variable", "header": "src/laige-core/include/laige/pools.h", "line": 112, "signature": "std::uint32_t peakInUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PoolStats::totalCreated", "kind": "variable", "header": "src/laige-core/include/laige/pools.h", "line": 113, "signature": "std::uint64_t totalCreated{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PoolStats::bytesCapacity", "kind": "variable", "header": "src/laige-core/include/laige/pools.h", "line": 114, "signature": "std::size_t bytesCapacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PoolStats::bytesInUse", "kind": "variable", "header": "src/laige-core/include/laige/pools.h", "line": 115, "signature": "std::size_t bytesInUse{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PoolHandle", "kind": "struct", "header": "src/laige-core/include/laige/pools.h", "line": 128, "signature": "struct PoolHandle", "summary": "A stable handle into a Pool (CPP-007): a 32-bit slot index plus a 32-bit generation. A handle is valid while the slot is live and its current generation equals the handle's. A generation bump on free invalidates every stale handle to that slot; a generation wrap after 2^32 frees of one slot is defined unsigned wraparound and is effectively unreachable (documented, not assertable — see Pool::isValid). A default handle {0, 0} is never valid.", "budget": null, "experimental": false}, + {"name": "laige::PoolHandle::index", "kind": "variable", "header": "src/laige-core/include/laige/pools.h", "line": 129, "signature": "std::uint32_t index{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::PoolHandle::generation", "kind": "variable", "header": "src/laige-core/include/laige/pools.h", "line": 130, "signature": "std::uint32_t generation{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::operator==", "kind": "function", "header": "src/laige-core/include/laige/pools.h", "line": 133, "signature": "inline bool operator==(PoolHandle a, PoolHandle b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::operator!=", "kind": "function", "header": "src/laige-core/include/laige/pools.h", "line": 136, "signature": "inline bool operator!=(PoolHandle a, PoolHandle b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ElementSlot", "kind": "struct", "header": "src/laige-core/include/laige/pools.h", "line": 146, "signature": "template struct ElementSlot", "summary": "Raw aligned storage for one element: a byte array with exactly the size of T (1 byte for an empty T) and its alignment, so placement new below is always well-formed (CPP-004). A plain struct — not std::aligned_storage, which is deprecated since C++23 and whose layout has changed across implementations — so ElementSlot is well-formed on every P0 toolchain.", "budget": null, "experimental": false}, + {"name": "laige::ArenaPool", "kind": "class", "header": "src/laige-core/include/laige/pools.h", "line": 155, "signature": "template class ArenaPool", "summary": "ArenaPool — arena-scoped, budgeted, reset-per-frame storage", "budget": null, "experimental": false}, + {"name": "laige::ArenaPool::Options", "kind": "struct", "header": "src/laige-core/include/laige/pools.h", "line": 160, "signature": "struct Options", "summary": "The element budget, fixed at construction (S-6). A budget of 0 is legal: every create() fails with BudgetExhausted.", "budget": null, "experimental": false}, + {"name": "laige::ArenaPool::Options::capacity", "kind": "variable", "header": "src/laige-core/include/laige/pools.h", "line": 161, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ArenaPool::ArenaPool", "kind": "constructor", "header": "src/laige-core/include/laige/pools.h", "line": 165, "signature": "explicit ArenaPool(Options options) noexcept : capacity_(options.capacity), block_(capacity_ > 0 ? std::make_unique[]>(capacity_) : nullptr), inUse_(0), peakInUse_(0), totalCreated_(0)", "summary": "One backing allocation for the whole arena (setup path, PERF-002).", "budget": null, "experimental": false}, + {"name": "laige::ArenaPool::ArenaPool", "kind": "constructor", "header": "src/laige-core/include/laige/pools.h", "line": 172, "signature": "ArenaPool(const ArenaPool&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ArenaPool::operator=", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 173, "signature": "ArenaPool& operator=(const ArenaPool&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ArenaPool::~ArenaPool", "kind": "destructor", "header": "src/laige-core/include/laige/pools.h", "line": 176, "signature": "~ArenaPool() noexcept", "summary": "Destroys every live element (no leak; ASan-verifiable). O(inUse).", "budget": null, "experimental": false}, + {"name": "laige::ArenaPool::ArenaPool", "kind": "constructor", "header": "src/laige-core/include/laige/pools.h", "line": 180, "signature": "ArenaPool(ArenaPool&& other) noexcept : capacity_(other.capacity_), block_(std::move(other.block_)), inUse_(other.inUse_), peakInUse_(other.peakInUse_), totalCreated_(other.totalCreated_)", "summary": "Move is an O(1) pointer swap; the source becomes an empty arena (capacity 0, every create() fails).", "budget": null, "experimental": false}, + {"name": "laige::ArenaPool::operator=", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 192, "signature": "ArenaPool& operator=(ArenaPool&& other) noexcept", "summary": "Move assignment: destroy the elements this currently owns, take over other's storage, and leave other a valid empty pool — the same net effect as a move construction. O(1).", "budget": null, "experimental": false}, + {"name": "laige::ArenaPool::create", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 212, "signature": "template [[nodiscard]] Result create(Args&&... args)", "summary": "Create one element in the next free slot (bump). O(1), no allocation; the element's constructor runs here (the op's only real work). Returns the slot index (valid until the next reset()). Beyond the budget: ErrorCode::BudgetExhausted (the arena never grows silently, S-2).", "budget": null, "experimental": false}, + {"name": "laige::ArenaPool::reset", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 228, "signature": "void reset() noexcept", "summary": "The per-frame release: destroy every live element and rewind the cursor. O(inUse), no allocation. peakInUse/totalCreated survive the reset (they are since-construction counters; a per-frame profiler diffs them). Idempotent.", "budget": null, "experimental": false}, + {"name": "laige::ArenaPool::at", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 239, "signature": "[[nodiscard]] T& at(std::uint32_t slot)", "summary": "Element access. at() asserts the slot is live (debug: a loud crash with a message; release: undefined behavior — the engine Result convention). get() is the null-safe read: nullptr for a slot >= inUse (after reset, or never created).", "budget": null, "experimental": false}, + {"name": "laige::ArenaPool::get", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 244, "signature": "[[nodiscard]] T* get(std::uint32_t slot) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ArenaPool::isValid", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 251, "signature": "[[nodiscard]] bool isValid(std::uint32_t slot) const noexcept", "summary": "True while the slot is live (i.e. until the next reset()).", "budget": null, "experimental": false}, + {"name": "laige::ArenaPool::capacity", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 255, "signature": "[[nodiscard]] std::uint32_t capacity() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ArenaPool::inUse", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 256, "signature": "[[nodiscard]] std::uint32_t inUse() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::ArenaPool::stats", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 260, "signature": "[[nodiscard]] PoolStats stats() const noexcept", "summary": "Accounting snapshot (PRD §10.4). bytesCapacity is the whole backing block; bytesInUse is the element footprint of the live prefix.", "budget": null, "experimental": false}, + {"name": "laige::Pool", "kind": "class", "header": "src/laige-core/include/laige/pools.h", "line": 280, "signature": "template class Pool", "summary": "Pool — stable-handle, generation-checked, bounded pool", "budget": null, "experimental": false}, + {"name": "laige::Pool::Options", "kind": "struct", "header": "src/laige-core/include/laige/pools.h", "line": 285, "signature": "struct Options", "summary": "The element budget, fixed at construction (S-6). A budget of 0 is legal: every create() fails with BudgetExhausted.", "budget": null, "experimental": false}, + {"name": "laige::Pool::Options::capacity", "kind": "variable", "header": "src/laige-core/include/laige/pools.h", "line": 286, "signature": "std::uint32_t capacity{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Pool::Pool", "kind": "constructor", "header": "src/laige-core/include/laige/pools.h", "line": 292, "signature": "explicit Pool(Options options) noexcept : capacity_(options.capacity), block_(capacity_ > 0 ? std::make_unique[]>(capacity_) : nullptr), generations_(capacity_ > 0 ? std::make_unique(capacity_) : nullptr), freeStack_(capacity_ > 0 ? std::make_unique(capacity_) : nullptr), alive_(capacity_ > 0 ? std::make_unique(capacity_) : nullptr), freeCount_(0), inUse_(0), peakInUse_(0), totalCreated_(0)", "summary": "Backing allocations for the whole pool (setup path, PERF-002): the element store, the per-slot generation table, the LIFO free-list stack (pre-filled 0..capacity-1), and the per-slot alive flag.", "budget": null, "experimental": false}, + {"name": "laige::Pool::Pool", "kind": "constructor", "header": "src/laige-core/include/laige/pools.h", "line": 310, "signature": "Pool(const Pool&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Pool::operator=", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 311, "signature": "Pool& operator=(const Pool&) = delete", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Pool::~Pool", "kind": "destructor", "header": "src/laige-core/include/laige/pools.h", "line": 314, "signature": "~Pool() noexcept", "summary": "Destroys every live element (no leak; ASan-verifiable). O(capacity).", "budget": null, "experimental": false}, + {"name": "laige::Pool::Pool", "kind": "constructor", "header": "src/laige-core/include/laige/pools.h", "line": 318, "signature": "Pool(Pool&& other) noexcept : capacity_(other.capacity_), block_(std::move(other.block_)), generations_(std::move(other.generations_)), freeStack_(std::move(other.freeStack_)), alive_(std::move(other.alive_)), freeCount_(other.freeCount_), inUse_(other.inUse_), peakInUse_(other.peakInUse_), totalCreated_(other.totalCreated_)", "summary": "Move is an O(1) pointer swap; the source becomes an empty pool (capacity 0: every create() fails, every handle invalid).", "budget": null, "experimental": false}, + {"name": "laige::Pool::operator=", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 334, "signature": "Pool& operator=(Pool&& other) noexcept", "summary": "Move assignment: destroy the elements this currently owns, take over other's storage, and leave other a valid empty pool — the same net effect as a move construction. O(1).", "budget": null, "experimental": false}, + {"name": "laige::Pool::create", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 357, "signature": "template [[nodiscard]] Result create(Args&&... args)", "summary": "Create one element in the next free slot (LIFO recycle). O(1), no allocation; the element's constructor runs here. Beyond the budget: ErrorCode::BudgetExhausted (the pool never grows silently, S-2).", "budget": null, "experimental": false}, + {"name": "laige::Pool::destroy", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 374, "signature": "[[nodiscard]] Status destroy(PoolHandle handle) noexcept", "summary": "Destroy one live element and return its slot to the free list. O(1), no allocation. The slot's generation is bumped, so every stale handle to it now fails isValid() (CPP-007). Invalid or stale handle: ErrorCode::InvalidArgument (a caller bug — S-9).", "budget": null, "experimental": false}, + {"name": "laige::Pool::at", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 388, "signature": "[[nodiscard]] T& at(PoolHandle handle)", "summary": "Element access. at() asserts the handle is live (debug: a loud crash with a message; release: undefined behavior — the engine Result convention). get() is the null-safe read: nullptr when the handle is stale, cleared, or from another pool.", "budget": null, "experimental": false}, + {"name": "laige::Pool::get", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 394, "signature": "[[nodiscard]] T* get(PoolHandle handle) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Pool::isValid", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 406, "signature": "[[nodiscard]] bool isValid(PoolHandle handle) const noexcept", "summary": "Generation-checked liveness (CPP-007): the slot exists, is live, and its current generation matches the handle's. A handle for a freed slot mismatches on the generation bump and can never pass again — except after 2^32 frees of that one slot (defined unsigned wrap; effectively unreachable, documented here as the one case the generation scheme does not rule out).", "budget": null, "experimental": false}, + {"name": "laige::Pool::clear", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 416, "signature": "void clear() noexcept", "summary": "Destroy every live element; every handle becomes stale; the capacity is unchanged and the pool is immediately reusable. O(capacity), no allocation. (Reset-per-frame workloads should use ArenaPool instead — its reset() is O(inUse) and has no free-list bookkeeping.)", "budget": null, "experimental": false}, + {"name": "laige::Pool::capacity", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 428, "signature": "[[nodiscard]] std::uint32_t capacity() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Pool::inUse", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 429, "signature": "[[nodiscard]] std::uint32_t inUse() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Pool::stats", "kind": "method", "header": "src/laige-core/include/laige/pools.h", "line": 434, "signature": "[[nodiscard]] PoolStats stats() const noexcept", "summary": "Accounting snapshot (PRD §10.4). bytesCapacity is the whole backing store (element slots + per-slot generation/alive/free-list bookkeeping); bytesInUse is the element footprint of live elements.", "budget": null, "experimental": false}, + {"name": "laige::Prng", "kind": "class", "header": "src/laige-core/include/laige/prng.h", "line": 126, "signature": "class Prng", "summary": "Prng — deterministic xorshift128+ with seeded, per-substream derivation (M0-CORE-06; contract preamble above; full API doc: docs/api/prng.md)", "budget": null, "experimental": false}, + {"name": "laige::Prng::kSplitmix64Increment", "kind": "variable", "header": "src/laige-core/include/laige/prng.h", "line": 130, "signature": "static constexpr std::uint64_t kSplitmix64Increment = 0x9E3779B97F4A7C15ull", "summary": "splitmix64 increment (Marsaglia's golden-ratio odd constant): the step between successive splitmix64 inputs and between substream seeds.", "budget": null, "experimental": false}, + {"name": "laige::Prng::kMixMultiplierA", "kind": "variable", "header": "src/laige-core/include/laige/prng.h", "line": 133, "signature": "static constexpr std::uint64_t kMixMultiplierA = 0xBF58476D1CE4E5B9ull", "summary": "splitmix64 mixing multipliers (Stafford 2018).", "budget": null, "experimental": false}, + {"name": "laige::Prng::kMixMultiplierB", "kind": "variable", "header": "src/laige-core/include/laige/prng.h", "line": 134, "signature": "static constexpr std::uint64_t kMixMultiplierB = 0x94D049BB133111EBull", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Prng::kFloat01Unit", "kind": "variable", "header": "src/laige-core/include/laige/prng.h", "line": 137, "signature": "static constexpr float kFloat01Unit = 0x1.0p-24f", "summary": "next_float01() resolution: one value is 2^-24 (24 mantissa bits).", "budget": null, "experimental": false}, + {"name": "laige::Prng::Prng", "kind": "constructor", "header": "src/laige-core/include/laige/prng.h", "line": 141, "signature": "explicit Prng(std::uint64_t seed) : seed_(seed), s0_(0), s1_(0)", "summary": "Construct the master stream for `seed`. The state is nonzero for every seed (splitmix64 is a bijection; see the preamble).", "budget": null, "experimental": false}, + {"name": "laige::Prng::next_u64", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 147, "signature": "std::uint64_t next_u64()", "summary": "One stream draw: the xorshift128+ output; advances the state.", "budget": null, "experimental": false}, + {"name": "laige::Prng::next_range", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 152, "signature": "std::uint32_t next_range(std::uint32_t min, std::uint32_t max)", "summary": "Uniform value in [min, max) (max - min must be in [1, 2^32 - 1]). Unbiased (Lemire reduction with rejection); expected < 2 draws. `min >= max` is a debug assert (documented UB in release).", "budget": null, "experimental": false}, + {"name": "laige::Prng::next_float01", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 156, "signature": "float next_float01()", "summary": "Uniform value in [0, 1) at 24-bit resolution: exactly k * 2^-24 for an integer k in [0, 2^24). Never 1.0; 0.0 with probability 2^-24.", "budget": null, "experimental": false}, + {"name": "laige::Prng::seed", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 160, "signature": "std::uint64_t seed() const", "summary": "The seed this stream was constructed from (save/replay identity, PRD §10.3; M1-DET-03 hashes this together with the substream id).", "budget": null, "experimental": false}, + {"name": "laige::Prng::substream", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 164, "signature": "Prng substream(std::uint32_t id) const", "summary": "A substream of this stream's seed: deriveSubstream(seed(), id). Independent stream position; id 0 == the master stream.", "budget": null, "experimental": false}, + {"name": "laige::Prng::deriveSubstream", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 169, "signature": "static Prng deriveSubstream(std::uint64_t seed, std::uint32_t id)", "summary": "Substream derivation (documented hash, see the preamble): Prng(seed + id * kSplitmix64Increment). Composes: deriveSubstream(deriveSubstream(seed, i), j) == deriveSubstream(seed, i+j).", "budget": null, "experimental": false}, + {"name": "laige::Prng::seedState", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 174, "signature": "static void seedState(std::uint64_t seed, std::uint64_t& part1, std::uint64_t& part2)", "summary": "Seed-to-state mapping (documented in the preamble). Exposed for determinism verification and M1 save/replay: a saved stream is (seed, part1, part2) and restores by seedState + stepState calls.", "budget": null, "experimental": false}, + {"name": "laige::Prng::stepState", "kind": "method", "header": "src/laige-core/include/laige/prng.h", "line": 180, "signature": "static void stepState(std::uint64_t& part1, std::uint64_t& part2)", "summary": "One transition step on a raw state (see the preamble). Exposed for determinism verification (the PrngPeriod suite reconstructs the state map over GF(2) from this) and M1 save/replay.", "budget": null, "experimental": false}, + {"name": "laige::splitMix64", "kind": "function", "header": "src/laige-core/include/laige/prng.h", "line": 192, "signature": "inline std::uint64_t splitMix64(std::uint64_t z)", "summary": "splitmix64 (Stafford 2018) — seeding-only; a bijection of u64.", "budget": null, "experimental": false}, + {"name": "laige::Result", "kind": "class", "header": "src/laige-core/include/laige/result.h", "line": 43, "signature": "template class Result", "summary": "A result carrying either a success value of type T or a failure of type E (default: laige::ErrorCode).", "budget": null, "experimental": false}, + {"name": "laige::Result::Result", "kind": "constructor", "header": "src/laige-core/include/laige/result.h", "line": 51, "signature": "Result() = delete", "summary": "An empty Result has no defined state and is therefore unrepresentable (API-008).", "budget": null, "experimental": false}, + {"name": "laige::Result::Result", "kind": "constructor", "header": "src/laige-core/include/laige/result.h", "line": 53, "signature": "Result(const Result&) = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Result::Result", "kind": "constructor", "header": "src/laige-core/include/laige/result.h", "line": 54, "signature": "Result(Result&&) = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Result::operator=", "kind": "method", "header": "src/laige-core/include/laige/result.h", "line": 55, "signature": "Result& operator=(const Result&) = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Result::operator=", "kind": "method", "header": "src/laige-core/include/laige/result.h", "line": 56, "signature": "Result& operator=(Result&&) = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Result::Result", "kind": "constructor", "header": "src/laige-core/include/laige/result.h", "line": 59, "signature": "[[nodiscard]] Result(T value) requires(!std::is_convertible_v) : value_(std::move(value)), error_(E{})", "summary": "Success carrying `value` (copied or moved in).", "budget": null, "experimental": false}, + {"name": "laige::Result::Result", "kind": "constructor", "header": "src/laige-core/include/laige/result.h", "line": 69, "signature": "[[nodiscard]] Result(const E& error) requires(!std::is_convertible_v) : value_(), error_(error)", "summary": "Failure carrying `error` (copied from a const reference; error codes are small scalars). The signature stays distinct from the value constructor even when T and E are the same type, which keeps the class well-formed on all conforming compilers (MSVC rejects two member declarations with identical parameter lists).", "budget": null, "experimental": false}, + {"name": "laige::Result::success", "kind": "method", "header": "src/laige-core/include/laige/result.h", "line": 76, "signature": "[[nodiscard]] static Result success(T value)", "summary": "Unambiguous factory forms (always available, including when T and E are mutually convertible).", "budget": null, "experimental": false}, + {"name": "laige::Result::failure", "kind": "method", "header": "src/laige-core/include/laige/result.h", "line": 81, "signature": "[[nodiscard]] static Result failure(E error)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Result::ok", "kind": "method", "header": "src/laige-core/include/laige/result.h", "line": 88, "signature": "[[nodiscard]] bool ok() const noexcept", "summary": "True when the result carries a success value.", "budget": null, "experimental": false}, + {"name": "laige::Result::isError", "kind": "method", "header": "src/laige-core/include/laige/result.h", "line": 89, "signature": "[[nodiscard]] bool isError() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Result::value", "kind": "method", "header": "src/laige-core/include/laige/result.h", "line": 93, "signature": "[[nodiscard]] const T& value() const noexcept", "summary": "The success value. Precondition: ok(). Debug builds assert; in release builds an unchecked call is undefined behavior.", "budget": null, "experimental": false}, + {"name": "laige::Result::error", "kind": "method", "header": "src/laige-core/include/laige/result.h", "line": 100, "signature": "[[nodiscard]] const E& error() const noexcept", "summary": "The failure value. Precondition: isError(). Debug builds assert; in release builds an unchecked call is undefined behavior.", "budget": null, "experimental": false}, + {"name": "laige::Result::takeValue", "kind": "method", "header": "src/laige-core/include/laige/result.h", "line": 109, "signature": "[[nodiscard]] T takeValue() &&", "summary": "Move the success value out of an rvalue result (ownership transfer, e.g. handing a freshly created resource to a container). Precondition: ok() and an rvalue result. Debug builds assert; in release builds an unchecked call is undefined behavior.", "budget": null, "experimental": false}, + {"name": "laige::Result::valueIfOk", "kind": "method", "header": "src/laige-core/include/laige/result.h", "line": 116, "signature": "[[nodiscard]] const T* valueIfOk() const noexcept", "summary": "Null-safe accessors (no precondition): nullptr when the result does not carry the requested state.", "budget": null, "experimental": false}, + {"name": "laige::Result::errorIfError", "kind": "method", "header": "src/laige-core/include/laige/result.h", "line": 119, "signature": "[[nodiscard]] const E* errorIfError() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Status", "kind": "class", "header": "src/laige-core/include/laige/result.h", "line": 140, "signature": "class Status", "summary": "A Result without a success value: an operation outcome only.", "budget": null, "experimental": false}, + {"name": "laige::Status::Status", "kind": "constructor", "header": "src/laige-core/include/laige/result.h", "line": 142, "signature": "Status() noexcept = default", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Status::Status", "kind": "constructor", "header": "src/laige-core/include/laige/result.h", "line": 143, "signature": "Status(ErrorCode code) noexcept : ok_(false), code_(code)", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Status::failure", "kind": "method", "header": "src/laige-core/include/laige/result.h", "line": 145, "signature": "[[nodiscard]] static Status failure(ErrorCode code) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Status::ok", "kind": "method", "header": "src/laige-core/include/laige/result.h", "line": 149, "signature": "[[nodiscard]] bool ok() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Status::isError", "kind": "method", "header": "src/laige-core/include/laige/result.h", "line": 150, "signature": "[[nodiscard]] bool isError() const noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::Status::error", "kind": "method", "header": "src/laige-core/include/laige/result.h", "line": 154, "signature": "[[nodiscard]] ErrorCode error() const noexcept", "summary": "The failure code. Precondition: isError(). Debug builds assert; in release builds an unchecked call is undefined behavior.", "budget": null, "experimental": false}, + {"name": "laige::Status::errorText", "kind": "method", "header": "src/laige-core/include/laige/result.h", "line": 161, "signature": "[[nodiscard]] const char* errorText() const noexcept", "summary": "The pre-rendered NFR-13.3 line for the failure code. Precondition: isError().", "budget": null, "experimental": false}, + {"name": "laige::sim::Fp32Pinned", "kind": "struct", "header": "src/laige-core/include/laige/sim_math.h", "line": 161, "signature": "struct Fp32Pinned", "summary": "IEEE-754 binary32 with pinned semantics (ADR 0002). See the header preamble for the pinned flag set and the full NaN/Inf policy.", "budget": null, "experimental": false}, + {"name": "laige::sim::Fp32Pinned::Scalar", "kind": "alias", "header": "src/laige-core/include/laige/sim_math.h", "line": 162, "signature": "using Scalar = float", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::Fp32Pinned::add", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 167, "signature": "static Scalar add(Scalar a, Scalar b) noexcept", "summary": "a + b: one IEEE binary32 addition, round-to-nearest-even. The build's -ffp-contract=off guarantees this does not fuse with an adjacent multiply (e.g. inside lerp()).", "budget": null, "experimental": false}, + {"name": "laige::sim::Fp32Pinned::sub", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 168, "signature": "static Scalar sub(Scalar a, Scalar b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::Fp32Pinned::mul", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 169, "signature": "static Scalar mul(Scalar a, Scalar b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::Fp32Pinned::div", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 172, "signature": "static Scalar div(Scalar a, Scalar b) noexcept", "summary": "IEEE binary32 division: x/0 = ±inf (no trap on P0 targets), 0/0 = NaN.", "budget": null, "experimental": false}, + {"name": "laige::sim::Fp32Pinned::sqrt", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 175, "signature": "static Scalar sqrt(Scalar a) noexcept", "summary": "Correctly-rounded binary32 square root (SSE2 vsqrtss / NEON vsqrt). sqrt of a negative value is NaN (IEEE); no trap.", "budget": null, "experimental": false}, + {"name": "laige::sim::Fp32Pinned::isNaN", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 185, "signature": "static bool isNaN(Scalar x) noexcept", "summary": "IEEE classification (the raw `==`/`!=` below are the deliberate bit-level comparisons the pinned NaN/Inf policy requires — the -Wfloat-equal heuristic is scoped away, CORE-010: no global suppression).", "budget": null, "experimental": false}, + {"name": "laige::sim::Fp32Pinned::isInf", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 186, "signature": "static bool isInf(Scalar x) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::Fpx16_16", "kind": "struct", "header": "src/laige-core/include/laige/sim_math.h", "line": 201, "signature": "struct Fpx16_16", "summary": "Q16.16 fixed-point (ADR 0002). Bit-exact across all builds, platforms, ISAs, and compilers by the C++20 language standard — no pinned flags required. The DEFAULT backend; required for lockstep and authoritative MMO. The full policy (saturating overflow, round-to-nearest ties-to-even, divide-by-zero, conversions, no NaN/Inf) is documented in laige/fpx16_16.h.", "budget": null, "experimental": false}, + {"name": "laige::sim::Fpx16_16::Scalar", "kind": "alias", "header": "src/laige-core/include/laige/sim_math.h", "line": 202, "signature": "using Scalar = fpx16_16", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::Fpx16_16::add", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 204, "signature": "static Scalar add(Scalar a, Scalar b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::Fpx16_16::sub", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 205, "signature": "static Scalar sub(Scalar a, Scalar b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::Fpx16_16::mul", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 207, "signature": "static Scalar mul(Scalar a, Scalar b) noexcept", "summary": "Rounded (ties-to-even) and saturating — see fpx16_16.h.", "budget": null, "experimental": false}, + {"name": "laige::sim::Fpx16_16::div", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 209, "signature": "static Scalar div(Scalar a, Scalar b) noexcept", "summary": "Divide by zero is defined (x/0 → ±max, 0/0 → +0) — never traps.", "budget": null, "experimental": false}, + {"name": "laige::sim::Fpx16_16::sqrt", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 211, "signature": "static Scalar sqrt(Scalar a) noexcept", "summary": "sqrt of a negative value is defined as +0 (no NaN exists).", "budget": null, "experimental": false}, + {"name": "laige::sim::Fpx16_16::isNaN", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 214, "signature": "static bool isNaN(Scalar) noexcept", "summary": "Q16.16 has no NaN and no infinity: total order, always finite.", "budget": null, "experimental": false}, + {"name": "laige::sim::Fpx16_16::isInf", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 215, "signature": "static bool isInf(Scalar) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath", "kind": "struct", "header": "src/laige-core/include/laige/sim_math.h", "line": 223, "signature": "template struct SimMath", "summary": "The SimMath op surface (ADR 0002): one interface, one template instantiation per backend", "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::Scalar", "kind": "alias", "header": "src/laige-core/include/laige/sim_math.h", "line": 225, "signature": "using Scalar = typename Backend::Scalar", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::Vec2", "kind": "struct", "header": "src/laige-core/include/laige/sim_math.h", "line": 231, "signature": "struct Vec2", "summary": "2D world/vector: positions, velocities, deltas. Default-initialized to the zero vector (the additive identity; all components +0). The coordinate system (axes, handedness, units) is defined in docs/concepts/coordinates.md (M0-DOC-02).", "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::Vec2::x", "kind": "variable", "header": "src/laige-core/include/laige/sim_math.h", "line": 232, "signature": "Scalar x{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::Vec2::y", "kind": "variable", "header": "src/laige-core/include/laige/sim_math.h", "line": 233, "signature": "Scalar y{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::Vec3", "kind": "struct", "header": "src/laige-core/include/laige/sim_math.h", "line": 237, "signature": "struct Vec3", "summary": "2.5D world space: (x, y) is the ground plane, z is depth/height.", "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::Vec3::x", "kind": "variable", "header": "src/laige-core/include/laige/sim_math.h", "line": 238, "signature": "Scalar x{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::Vec3::y", "kind": "variable", "header": "src/laige-core/include/laige/sim_math.h", "line": 239, "signature": "Scalar y{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::Vec3::z", "kind": "variable", "header": "src/laige-core/include/laige/sim_math.h", "line": 240, "signature": "Scalar z{}", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::create", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 246, "signature": "[[nodiscard]] static SimMath create() noexcept", "summary": "Factory (ADR 0002): backend selection is compile-time dispatch, factory-selected once per engine/zone init. The returned handle is stateless — holding one for a sim session costs nothing.", "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::add", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 252, "signature": "[[nodiscard]] static Scalar add(Scalar a, Scalar b) noexcept", "summary": "Arithmetic (IEEE per the backend's policy; NaN/Inf per the header preamble; division by zero does not trap)", "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::sub", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 255, "signature": "[[nodiscard]] static Scalar sub(Scalar a, Scalar b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::mul", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 258, "signature": "[[nodiscard]] static Scalar mul(Scalar a, Scalar b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::div", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 261, "signature": "[[nodiscard]] static Scalar div(Scalar a, Scalar b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::add", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 266, "signature": "[[nodiscard]] static Vec2 add(Vec2 a, Vec2 b) noexcept", "summary": "Component-wise vector arithmetic (same IEEE policy per component).", "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::sub", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 269, "signature": "[[nodiscard]] static Vec2 sub(Vec2 a, Vec2 b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::mul", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 272, "signature": "[[nodiscard]] static Vec2 mul(Vec2 a, Vec2 b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::mul", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 275, "signature": "[[nodiscard]] static Vec2 mul(Vec2 a, Scalar s) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::mul", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 278, "signature": "[[nodiscard]] static Vec2 mul(Scalar s, Vec2 a) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::length", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 293, "signature": "[[nodiscard]] static Scalar length(Vec2 v) noexcept", "summary": "length is exactly sqrt(x*x + y*y): two muls, one add, one correctly-rounded sqrt, in that order. fp32_pinned: the pinned -ffp-contract=off keeps x*x + y*y from FMA-contraction, which would round once and change the result. fpx16_16: the squarings and the sum are saturating Q16.16 ops, so length is accurate while x*x + y*y stays inside the Q16.16 range (|v| ≲ 181.02 per component; beyond that the result saturates — defined, deterministic, documented in fpx16_16.h and docs/api/sim_math.md).", "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::length", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 297, "signature": "[[nodiscard]] static Scalar length(Vec3 v) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::normalize", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 309, "signature": "[[nodiscard]] static Vec2 normalize(Vec2 v) noexcept", "summary": "Unit vector: v / length(v), component-wise division in the backend's format. The zero vector is defined to normalize to the zero vector (SimMath never injects NaN from a zero-length input; the fixed-point backend has no NaN at all). NaN/inf components propagate per the backend's policy (fp32_pinned: IEEE — an infinite vector can normalize to NaN components).", "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::normalize", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 314, "signature": "[[nodiscard]] static Vec3 normalize(Vec3 v) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::lerp", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 326, "signature": "[[nodiscard]] static Scalar lerp(Scalar a, Scalar b, Scalar t) noexcept", "summary": "lerp is exactly a + (b - a) * t (see the header preamble for the rounding contract and the non-interchangeability with a*(1-t)+b*t). t outside [0,1] extrapolates by the same expression (defined).", "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::lerp", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 329, "signature": "[[nodiscard]] static Vec2 lerp(Vec2 a, Vec2 b, Scalar t) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::clamp", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 337, "signature": "[[nodiscard]] static Scalar clamp(Scalar x, Scalar lo, Scalar hi) noexcept", "summary": "Bounding: requires `lo` and `hi` finite and `lo <= hi` (debug builds assert; release builds treat a violation as undefined behavior — the engine's Result/Status convention). NaN x → NaN; ±inf x → the corresponding bound.", "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::clamp", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 344, "signature": "[[nodiscard]] static Vec2 clamp(Vec2 v, Vec2 lo, Vec2 hi) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::eq", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 362, "signature": "static bool eq(Scalar a, Scalar b) noexcept", "summary": "NaN policy: less/lessEqual/greater/greaterEqual/equals are false when either operand is NaN; notEquals is true. The eq/ne primitives below are the only place in the engine where a raw floating-point equality comparison appears — deliberate bit-level comparisons that implement the documented policy — so the -Wfloat-equal heuristic (a -Wall member, which does not understand the pinned NaN/Inf contract) is scoped away there (CORE-010: no global suppression).", "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::ne", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 363, "signature": "static bool ne(Scalar a, Scalar b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::less", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 368, "signature": "static bool less(Scalar a, Scalar b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::lessEqual", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 369, "signature": "static bool lessEqual(Scalar a, Scalar b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::greater", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 370, "signature": "static bool greater(Scalar a, Scalar b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::greaterEqual", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 371, "signature": "static bool greaterEqual(Scalar a, Scalar b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::equals", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 372, "signature": "static bool equals(Scalar a, Scalar b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::notEquals", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 373, "signature": "static bool notEquals(Scalar a, Scalar b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::isOrdered", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 378, "signature": "static bool isOrdered(Scalar a, Scalar b) noexcept", "summary": "True iff neither operand is NaN (IEEE 754-2008 `totalOrder` predicate, i.e. the negation of `isunordered` — not the negation of the arithmetic != operator). Always true for fixed-point backends (total order).", "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::isNaN", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 384, "signature": "static bool isNaN(Scalar x) noexcept", "summary": "Value classification, delegated to the backend: IEEE for floating-point backends; fixed-point backends have no NaN/Inf (isNaN/isInf always false, isFinite always true).", "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::isInf", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 385, "signature": "static bool isInf(Scalar x) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::isFinite", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 386, "signature": "static bool isFinite(Scalar x) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::equals", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 389, "signature": "static bool equals(Vec2 a, Vec2 b) noexcept", "summary": "Vector equality (component-wise; treats ±0 as equal, per IEEE).", "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::notEquals", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 392, "signature": "static bool notEquals(Vec2 a, Vec2 b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::equals", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 393, "signature": "static bool equals(Vec3 a, Vec3 b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMath::notEquals", "kind": "method", "header": "src/laige-core/include/laige/sim_math.h", "line": 396, "signature": "static bool notEquals(Vec3 a, Vec3 b) noexcept", "summary": null, "budget": null, "experimental": false}, + {"name": "laige::sim::SimMathFpx16", "kind": "alias", "header": "src/laige-core/include/laige/sim_math.h", "line": 405, "signature": "using SimMathFpx16 = SimMath", "summary": "The `fpx16_16` SimMath (this step; `determinism.math` config id \"fixed_point_16_16\"). The DEFAULT backend (ADR 0002); required for lockstep (AC-10.3) and authoritative MMO. The `determinism.math` config plumbing lands with the config step (FR-1.5); until then, deterministic/lockstep engine code targets SimMathFpx16 and opt-in IEEE-float zones target SimMathFp32.", "budget": null, "experimental": false}, + {"name": "laige::sim::SimMathFp32", "kind": "alias", "header": "src/laige-core/include/laige/sim_math.h", "line": 410, "signature": "using SimMathFp32 = SimMath", "summary": "The `fp32_pinned` SimMath (M0-CORE-03; `determinism.math` config id \"float_pinned_32\"). Opt-in for single-player / non-lockstep zones that want IEEE float semantics (ADR 0002).", "budget": null, "experimental": false} + ] +} diff --git a/roadmap/M0-foundations.md b/roadmap/M0-foundations.md index 9e40536..7f30970 100644 --- a/roadmap/M0-foundations.md +++ b/roadmap/M0-foundations.md @@ -751,7 +751,7 @@ No rendering, no physics, no networking yet — `laige-core` only. ## Tooling -- [ ] **M0-TOOL-01 · API manifest generator** +- [x] **M0-TOOL-01 · API manifest generator** - **Refs:** NFR-13.1; PRD §9.4 - **Depends:** M0-BUILD-01, M0-CORE-01 - **Scope:** @@ -760,6 +760,41 @@ No rendering, no physics, no networking yet — `laige-core` only. - Keep the scanner deliberately narrow (doxygen-comment-driven) so it stays maintainable. - **Verify:** `cmake --build build --target laige-api` produces `laige-api.json`; adding a public symbol without regenerating fails CI; manifest for current (small) API is complete. - **Size:** ~400 lines (split if needed: scanner vs CI check) + - **Decision (2026-09-12):** single C++20 tool + `tools/api/laige-api-scanner` (~1,900 lines — over the ~400-line + estimate: the scanner is a line-oriented state machine with loud + failure on every unsupported construct (CORE-008), not a regex + pass, and it reuses `laige::parseJson` for the `--check` diff, so + one file carries scanner + CLI + manifest I/O; splitting adds a + second target for no maintainability gain at this size) plus a + `laige-api` custom target (the checked-in `laige-api.json` at the + repo root is regenerated by `cmake --build build --target + laige-api`). Manifest: `version: 1`, `generatedBy: "laige-api"`, + `headers[]` (sorted, repo-relative), `symbols[]` with fixed keys + `name/kind/header/line/signature/summary/budget/experimental` — + deterministic (no timestamps), so byte-identical regeneration is + the CI drift check. Symbols: `class`, `struct`, `enum`, `function`, + `method`, `constructor`, `destructor`, `variable`, `alias`, + `enumerator`, `macro`; fully qualified names (macros bare); + public-only access, `namespace detail` excluded, out-of-line member + definitions and forward declarations add no entries. Doc + association: consecutive `//` blocks directly above the declaration + (blank comment line = paragraph break; banner lines finalize; + physical blank line / closing brace / non-transparent directive + discard; `@budget ` and `@experimental` tags parsed). + Unsupported constructs fail loudly (block comments, raw strings, + lambdas at declaration level, function-pointer parameter types, + `operator()`, `extern "C"` blocks, compound typedefs, `#define` in + class bodies). `--check FILE`: byte compare; on mismatch a + symbol-level diff (added/removed/changed, keyed by + name+header+line, max 20 shown) via `laige::parseJson`. Exit + 0 OK / 1 stale / 2 error. CI: `api-manifest` job in + `ci-pull.yml`/`ci.yml` regenerates and fails on drift; CTest + (`tests/api`) covers the fixture tree (exact manifest bytes), + fresh/stale checks, the unsupported-construct failure, the root + error, and the real-tree drift check in every P0 job. Current + manifest: 376 symbols from 10 headers (all in `laige-core`; the + other M0 modules have no `include/` directories yet). - [ ] **M0-TOOL-02 · Determinism checker skeleton** - **Refs:** FR-11.5; AGENTS ARCH-010, TEST-004 diff --git a/src/laige-core/json.cpp b/src/laige-core/json.cpp index 4a20904..ae86e6c 100644 --- a/src/laige-core/json.cpp +++ b/src/laige-core/json.cpp @@ -81,7 +81,11 @@ bool decodeUtf8(std::string_view in, std::size_t& pos, std::uint32_t& cp) { return false; // 0x80-0xC1 and 0xF5-0xFF: never valid } -bool isValidUtf8(std::string_view s) { +// [[maybe_unused]]: referenced only from the debug asserts below (the +// fromString / setString UTF-8 preconditions); with NDEBUG (release +// builds) those asserts are compiled out and the helper would be unused +// (CORE-010: no new warnings under -Werror). +[[maybe_unused]] bool isValidUtf8(std::string_view s) { std::size_t pos = 0; while (pos < s.size()) { std::uint32_t cp = 0; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 67b6140..2556209 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -67,3 +67,7 @@ add_subdirectory(laige-core) # Tooling checks (M0-CI-03): the include-graph lint runs against fixture # trees and the real repository tree (tests/tools). add_subdirectory(tools) + +# Public API manifest checks (M0-TOOL-01): the scanner runs against fixture +# trees and the real repository tree (tests/api). +add_subdirectory(api) diff --git a/tests/api/CMakeLists.txt b/tests/api/CMakeLists.txt new file mode 100644 index 0000000..6f45908 --- /dev/null +++ b/tests/api/CMakeLists.txt @@ -0,0 +1,237 @@ +# laige-api manifest tests (M0-TOOL-01) +# +# tools/api/laige-api-scanner is a C++ tool, so it gets a CTest suite of +# its own: the scanner runs against synthetic fixture trees (generated +# here at configure time) and against the real repository tree: +# +# api-fixture-scan the fixture tree -> exact manifest +# api-check-fresh --check on a current copy -> exit 0 +# api-check-stale --check on a stale copy -> exit 1 + diff +# api-unsupported-construct a block comment -> exit 2 +# api-root-error a nonexistent root -> exit 2 +# api-real-tree the live repository -> exit 0 (drift) +# +# The real-tree test is the same check the CI drift job runs, so a public +# header change that misses the manifest lands red in every P0 job. +# +# Each test is a generated `cmake -P` check script (see below) asserting +# the exit code, required output fragments, and — for the scan test — the +# exact manifest bytes (tests/api/expected-fixture-api.json). The fixture +# and its expected manifest are a contract pair: a scanner change that +# alters either one MUST update both in the same change (CORE-007). + +set(FX "${CMAKE_BINARY_DIR}/api-fixtures") +file(REMOVE_RECURSE "${FX}") + +# --- fixture: one header exercising every manifest rule ------------------- +# detail-namespace exclusion, private-member exclusion, @budget and +# @experimental tags, multi-paragraph docs (first paragraph wins), +# enumerators with/without values, alias, function-like macro, and +# constructor / destructor / method classification. +file(MAKE_DIRECTORY "${FX}/api/src/laige-core/include/laige") +file(WRITE "${FX}/api/src/laige-core/include/laige/fixt.h" "#pragma once + +#include + +namespace laige { + +namespace detail { +struct Hidden { + int x; +}; +} // namespace detail + +// A public function with a budget tag. +// @budget 20 us +[[nodiscard]] int fnA() noexcept; + +// Tag two. +// @experimental +// +// Second paragraph ignored. +int fnB(); + +enum class E : std::uint8_t { + A = 0, + B, +}; + +struct S { + int pub; + private: + int sec; + public: + int pub2; +}; + +// Alias doc. +using AliasT = S; + +#define FIXT_MACRO(x) ((x) + 1) + +class C { + public: + C(); + ~C(); + int m(); +}; + +} // namespace laige +") + +# --- expected manifest for the fixture (exact serializer bytes) ------------ +# Must stay in lockstep with the fixture above and with the scanner's +# serializer (see serializeManifest in tools/api/laige-api.cpp). +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/expected-fixture-api.json" "{ + \"version\": 1, + \"generatedBy\": \"laige-api\", + \"headers\": [ + \"src/laige-core/include/laige/fixt.h\" + ], + \"symbols\": [ + {\"name\": \"laige::fnA\", \"kind\": \"function\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 15, \"signature\": \"[[nodiscard]] int fnA() noexcept\", \"summary\": \"A public function with a budget tag.\", \"budget\": \"20 us\", \"experimental\": false}, + {\"name\": \"laige::fnB\", \"kind\": \"function\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 21, \"signature\": \"int fnB()\", \"summary\": \"Tag two.\", \"budget\": null, \"experimental\": true}, + {\"name\": \"laige::E\", \"kind\": \"enum\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 23, \"signature\": \"enum class E : std::uint8_t\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::E::A\", \"kind\": \"enumerator\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 24, \"signature\": \"A = 0\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::E::B\", \"kind\": \"enumerator\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 25, \"signature\": \"B\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::S\", \"kind\": \"struct\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 28, \"signature\": \"struct S\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::S::pub\", \"kind\": \"variable\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 29, \"signature\": \"int pub\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::S::pub2\", \"kind\": \"variable\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 33, \"signature\": \"int pub2\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::AliasT\", \"kind\": \"alias\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 37, \"signature\": \"using AliasT = S\", \"summary\": \"Alias doc.\", \"budget\": null, \"experimental\": false}, + {\"name\": \"FIXT_MACRO\", \"kind\": \"macro\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 39, \"signature\": \"#define FIXT_MACRO(x)\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::C\", \"kind\": \"class\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 41, \"signature\": \"class C\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::C::C\", \"kind\": \"constructor\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 43, \"signature\": \"C()\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::C::~C\", \"kind\": \"destructor\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 44, \"signature\": \"~C()\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::C::m\", \"kind\": \"method\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 45, \"signature\": \"int m()\", \"summary\": null, \"budget\": null, \"experimental\": false} + ] +} +") + +# --- stale manifest for the fixture (two deliberate differences) ----------- +# Same manifest as the expected one, minus laige::E::B, with laige::S's +# summary tampered: --check must report exactly one addition and one +# change (the S entry keeps its name/header/line, so it is a change, not +# a removal). +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/stale-fixture-api.json" "{ + \"version\": 1, + \"generatedBy\": \"laige-api\", + \"headers\": [ + \"src/laige-core/include/laige/fixt.h\" + ], + \"symbols\": [ + {\"name\": \"laige::fnA\", \"kind\": \"function\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 15, \"signature\": \"[[nodiscard]] int fnA() noexcept\", \"summary\": \"A public function with a budget tag.\", \"budget\": \"20 us\", \"experimental\": false}, + {\"name\": \"laige::fnB\", \"kind\": \"function\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 21, \"signature\": \"int fnB()\", \"summary\": \"Tag two.\", \"budget\": null, \"experimental\": true}, + {\"name\": \"laige::E\", \"kind\": \"enum\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 23, \"signature\": \"enum class E : std::uint8_t\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::E::A\", \"kind\": \"enumerator\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 24, \"signature\": \"A = 0\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::S\", \"kind\": \"struct\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 28, \"signature\": \"struct S\", \"summary\": \"tampered summary\", \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::S::pub\", \"kind\": \"variable\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 29, \"signature\": \"int pub\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::S::pub2\", \"kind\": \"variable\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 33, \"signature\": \"int pub2\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::AliasT\", \"kind\": \"alias\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 37, \"signature\": \"using AliasT = S\", \"summary\": \"Alias doc.\", \"budget\": null, \"experimental\": false}, + {\"name\": \"FIXT_MACRO\", \"kind\": \"macro\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 39, \"signature\": \"#define FIXT_MACRO(x)\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::C\", \"kind\": \"class\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 41, \"signature\": \"class C\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::C::C\", \"kind\": \"constructor\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 43, \"signature\": \"C()\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::C::~C\", \"kind\": \"destructor\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 44, \"signature\": \"~C()\", \"summary\": null, \"budget\": null, \"experimental\": false}, + {\"name\": \"laige::C::m\", \"kind\": \"method\", \"header\": \"src/laige-core/include/laige/fixt.h\", \"line\": 45, \"signature\": \"int m()\", \"summary\": null, \"budget\": null, \"experimental\": false} + ] +} +") + +# --- fixture: an unsupported construct the scanner must reject ------------- +file(MAKE_DIRECTORY "${FX}/blockcomment/src/laige-core/include/laige") +file(WRITE "${FX}/blockcomment/src/laige-core/include/laige/bad.h" "#pragma once + +namespace laige { + +/* a block comment: unsupported by the scanner */ +struct S { + int x; +}; + +} // namespace laige +") + +# --- the tests --------------------------------------------------------------- +# Each test runs a generated `cmake -P` check script +# (expect-api-result.cmake.in) that asserts the scanner's exit code AND +# the required output fragments (and, for the scan test, the exact output +# bytes). CTest properties cannot express this safely: CTest inverts +# PASS_REGULAR_EXPRESSION when WILL_FAIL is set (verified on CMake 4.4.3), +# and a crash would exit non-zero like a scan failure (CORE-008). +function(laige_add_api_test name expect_exit root mode file) + # Space-separated quoted list (semicolon lists confuse the CMake + # script lexer in generated files — verified on CMake 4.4.3). + set(_cmd "\"--root\" \"${root}\" \"--${mode}\" \"${file}\"") + set(_checks "") + foreach(_needle IN LISTS ARGN) + string(REPLACE "\\" "\\\\" _esc "${_needle}") + string(REPLACE "\"" "\\\"" _esc "${_esc}") + string(REPLACE "(" "\\(" _esc "${_esc}") + string(REPLACE ")" "\\)" _esc "${_esc}") + string(REPLACE "+" "\\+" _esc "${_esc}") + string(REPLACE "." "\\." _esc "${_esc}") + string(APPEND _checks + "if(NOT _text MATCHES \"${_esc}\")\n" + " string(APPEND _problems \"output missing '<${_esc}'>; \")\n" + "endif()\n") + endforeach() + set(SCANNER "${CMAKE_BINARY_DIR}/bin/laige-api-scanner") + set(CMD "${_cmd}") + set(EXPECT_EXIT "${expect_exit}") + set(OUT_FILE "") + set(EXPECTED_FILE "") + if(mode STREQUAL "out") + set(OUT_FILE "${file}") + endif() + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/expect-api-result.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/${name}.cmake" @ONLY) + add_test(NAME ${name} COMMAND ${CMAKE_COMMAND} -P + "${CMAKE_CURRENT_BINARY_DIR}/${name}.cmake") +endfunction() + +# The scan test also compares the written manifest byte-for-byte against +# the expected fixture manifest (the template's EXPECTED_FILE hook). +set(_scan_out "${CMAKE_CURRENT_BINARY_DIR}/fixture-api.json") +set(_scan_root "${FX}/api") +set(_scan_expected "${CMAKE_CURRENT_BINARY_DIR}/expected-fixture-api.json") +set(_cmd "\"--root\" \"${_scan_root}\" \"--out\" \"${_scan_out}\"") +set(SCANNER "${CMAKE_BINARY_DIR}/bin/laige-api-scanner") +set(CMD "${_cmd}") +set(EXPECT_EXIT "0") +set(OUT_FILE "${_scan_out}") +set(EXPECTED_FILE "${_scan_expected}") +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/expect-api-result.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/api-fixture-scan.cmake" @ONLY) +add_test(NAME api-fixture-scan COMMAND ${CMAKE_COMMAND} -P + "${CMAKE_CURRENT_BINARY_DIR}/api-fixture-scan.cmake") + +laige_add_api_test(api-check-fresh 0 "${FX}/api" check + "${CMAKE_CURRENT_BINARY_DIR}/expected-fixture-api.json" + "laige-api: OK" + "is up to date (14 symbol(s) from the current public headers)" + ) + +laige_add_api_test(api-check-stale 1 "${FX}/api" check + "${CMAKE_CURRENT_BINARY_DIR}/stale-fixture-api.json" + "is stale (2 difference(s) vs the regenerated manifest)" + "+ added: laige::E::B" + "~ changed: laige::S (summary)" + "regenerate with:" + ) + +laige_add_api_test(api-unsupported-construct 2 "${FX}/blockcomment" out + "${CMAKE_CURRENT_BINARY_DIR}/fixture-bad.json" + "block comments are not supported" + ) + +laige_add_api_test(api-root-error 2 "${CMAKE_SOURCE_DIR}/no-such-root" out + "${CMAKE_CURRENT_BINARY_DIR}/fixture-bad.json" + "root is not a directory" + ) + +# The live-tree drift check: the checked-in manifest must match the +# current public headers exactly (the CI drift job runs the same check). +laige_add_api_test(api-real-tree 0 "${CMAKE_SOURCE_DIR}" check + "${CMAKE_SOURCE_DIR}/laige-api.json" + "laige-api: OK" + "is up to date" + ) diff --git a/tests/api/expect-api-result.cmake.in b/tests/api/expect-api-result.cmake.in new file mode 100644 index 0000000..3eef20a --- /dev/null +++ b/tests/api/expect-api-result.cmake.in @@ -0,0 +1,44 @@ +# Generated by tests/api/CMakeLists.txt for one laige-api CTest test +# (M0-TOOL-01) — do not edit. +# +# Runs laige-api-scanner against one tree and asserts BOTH the exit code +# and the required output fragments (the needle checks below are +# generated per test). The assertions live here, in a CMake script, +# instead of in CTest properties: CTest inverts PASS_REGULAR_EXPRESSION +# when WILL_FAIL is set (verified on CMake 4.4.3 — a matching regex then +# makes the test fail), and a plain exit-code expectation would let a +# crash (also non-zero) masquerade as a correct scan failure. +# +# The script exits non-zero (failing the CTest test) on any mismatch and +# prints the full scanner output for diagnosis. + +set(_cmd @CMD@) +execute_process( + COMMAND @SCANNER@ ${_cmd} + RESULT_VARIABLE _rc + OUTPUT_VARIABLE _out + ERROR_VARIABLE _err +) + +set(_text "${_out} +${_err}") + +set(_problems "") +if(NOT _rc EQUAL @EXPECT_EXIT@) + set(_problems "exit code ${_rc} (expected @EXPECT_EXIT@)") +endif() +if(NOT "@EXPECTED_FILE@" STREQUAL "") + # Byte comparison of the written manifest against the expected copy. + file(READ "@EXPECTED_FILE@" _expected) + file(READ "@OUT_FILE@" _actual) + if(NOT _actual STREQUAL _expected) + set(_problems "${_problems}manifest differs from expected bytes; ") + endif() +endif() +@CHECKS@ +if(NOT _problems STREQUAL "") + message(FATAL_ERROR + "laige-api check failed [${_problems}]\n" + "--- scanner output ---\n${_text}\n--- end of scanner output ---") +endif() +message("laige-api check OK: exit @EXPECT_EXIT@, required output present") diff --git a/tools/api/CMakeLists.txt b/tools/api/CMakeLists.txt new file mode 100644 index 0000000..0514114 --- /dev/null +++ b/tools/api/CMakeLists.txt @@ -0,0 +1,30 @@ +# laige-api-scanner (M0-TOOL-01): the public API manifest generator. +# +# Scans the public headers (src//include for the PRD §10.1 +# modules) and emits the deterministic machine-readable manifest +# laige-api.json. The full manifest contract — symbol kinds, doc +# association, unsupported constructs, exit codes — is in the header +# comment of laige-api.cpp. +# +# Gated with the test suite like tools/fuzz and tools/bench: a +# library-only build does not need it. + +add_executable(laige-api-scanner laige-api.cpp) +laige_apply_engine_policy(laige-api-scanner) +target_link_libraries(laige-api-scanner PRIVATE laige-core) + +# Canonical regeneration target (PRD §9.4, NFR-13.1): +# +# cmake --build build --target laige-api +# +# rewrites the checked-in ${CMAKE_SOURCE_DIR}/laige-api.json from the +# current public headers. CI regenerates it in a temporary workdir and +# fails on any drift against the checked-in copy (the checked-in file is +# never rewritten by CI — it is a reviewable diff). +add_custom_target(laige-api + COMMAND $ + --root ${CMAKE_SOURCE_DIR} + --out ${CMAKE_SOURCE_DIR}/laige-api.json + COMMENT "Regenerating laige-api.json (public API manifest)" + VERBATIM) +add_dependencies(laige-api laige-api-scanner) diff --git a/tools/api/laige-api.cpp b/tools/api/laige-api.cpp new file mode 100644 index 0000000..7428946 --- /dev/null +++ b/tools/api/laige-api.cpp @@ -0,0 +1,1915 @@ +// ============================================================================ +// laige-api — public API manifest generator (M0-TOOL-01) +// +// Roadmap: roadmap/M0-foundations.md, step M0-TOOL-01 +// PRD refs: §9.4 (machine-readable API manifest), NFR-13.1 (every public +// symbol machine-readable; CI regenerates the manifest and +// fails on drift), NFR-13.4 (deterministic tool output) +// AGENTS: CORE-006 (docs/manifest update in the same change), +// CORE-008 (no silent failure: unsupported constructs fail +// loudly), CORE-004 (smallest complete design), +// DOC-003 (the manifest must match shipped names) +// +// Usage: +// laige-api-scanner --root REPO_ROOT (--out FILE | --check FILE) +// +// --root REPO_ROOT Repository root (default: "."). +// --out FILE Write the regenerated manifest to FILE. +// --check FILE Regenerate in memory and compare with FILE: +// byte-identical -> exit 0; stale -> exit 1 with a +// symbol-level diff; unreadable/invalid FILE -> exit 2. +// +// Exit codes: 0 = OK · 1 = stale manifest (--check) · 2 = error (usage, +// I/O, unsupported construct, invalid checked-in manifest). +// +// What it emits (laige-api.json) +// ------------------------------ +// { +// "version": 1, +// "generatedBy": "laige-api", +// "headers": [ "src//include/...", ... ], // sorted +// "symbols": [ +// { "name": "laige::Pool::acquire", "kind": "method", +// "header": "src/laige-core/include/laige/pools.h", "line": 123, +// "signature": "PoolHandle acquire()", +// "summary": "Allocates one element from the pool.", +// "budget": "O(1), no allocation on the success path", +// "experimental": false }, +// ... +// ] +// } +// +// One entry per PUBLIC symbol. Symbol kinds: class, struct, enum, +// function, method, constructor, destructor, variable, alias, +// enumerator, macro. Names are fully qualified (namespace + class +// path; macros are bare). "line" is the first line of the +// declaration. "summary" is the first paragraph of the doxygen comment +// block directly above the declaration (null when absent); +// "@budget " and "@experimental" tag lines in that block fill +// "budget" / "experimental". +// The output is deterministic (no timestamps, NFR-13.4): regenerating +// over unchanged headers is byte-identical, which is what --check and +// the api_manifest CTest test rely on. +// +// Which symbols count as public +// ------------------------------ +// - Headers are scanned from each module's public include root +// (src//include, PRD §10.1); .h files anywhere under it. +// The module list mirrors MODULE_STACK in tools/laige-include-lint +// (PRD §10.1); if the PRD changes the map, update both in one change +// (CORE-006). +// - namespace scope: public. +// - class/struct scope: public access only (members after `private:` +// / `protected:` are excluded; the default access is struct=public, +// class=private, per the C++ standard). +// - everything inside a namespace named `detail` is excluded +// (implementation detail, per the public-header convention). +// - namespaces themselves are not symbols (their contents are). +// - `friend` declarations, forward declarations (`class X;`), +// out-of-line member definitions (their in-class declaration is the +// canonical entry), statements (static_assert, ...), and +// preprocessor control directives produce no symbol entries. +// +// Scanner scope (deliberately narrow, roadmap) +// -------------------------------------------- +// The scanner is a doxygen-comment-driven text walker — not a C++ +// parser. It understands exactly the constructs the public headers use: +// +// - namespaces (incl. nested `a::b`), class/struct/enum declarations +// with access sections, template prefixes (`template <...>`, incl. +// defaults and a `requires(...)` clause after the prefix), attribute +// prefixes (`[[nodiscard]]`), default arguments, brace-init, +// `using` aliases and `typedef`, `enum class` with enumerators +// (incl. initializers and trailing comma), function definitions with +// bodies (bodies are skipped whole), operators (`operator==`, +// `operator<=>`, ...), destructors, constructors, `= default` / +// `= 0` special members, #define macros (object-like and function- +// like, incl. backslash continuations), and the transparent +// directives (#if/#ifdef/#ifndef/#elif/#else/#endif and +// #pragma [GCC|clang] diagnostic ...). +// +// Anything else — block comments /* */, raw string literals, inline +// namespaces, lambdas at declaration level, function-pointer types in +// parameter lists, the function-call operator `operator()`, `extern +// "C"` blocks, compound typedefs (`typedef struct {...} X;`), and +// #define inside a class body — is a LOUD scanner error (CORE-008). +// Comments inside multi-line declarations are skipped (they carry no +// manifest data). When a future header step needs an unsupported +// construct, extend the scanner in the same change (CORE-006). +// +// Documentation association (the doxygen rules implemented) +// --------------------------------------------------------- +// A doxygen block is a run of consecutive `//` comment lines directly +// above a declaration. Paragraphs split at blank comment lines (`//` +// with nothing else). Banner lines — a pure run of `-`, `=`, `~`, `*` +// characters, or a section header (a leading run of those characters, +// text, and a trailing run of at least three, e.g. +// `// --- Named values (CORE-005) ---- ...`) — finalize the current +// block but do not discard it. A physical blank +// line finalizes the block (the block stays pending; the next comment +// line starts a fresh block that replaces it, so the last block above a +// declaration wins). The block attaches to the next symbol-producing +// declaration or #define; it is discarded by a closing brace, an access +// specifier, or a non-transparent directive. A comment line that +// follows a code line carrying its own trailing `//` comment (or that +// is such a trailing comment itself) is treated as that trailing +// comment's wrapped continuation: it starts no block and extends none. +// Summary = first non-empty paragraph. +// +// Dependencies: laige-core only (its bounded JSON parser, M0-CORE-07, +// is used by --check to parse the checked-in manifest). C++20, +// no exceptions, no RTTI (NFR-8.10). Single translation unit. +// ============================================================================ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "laige/json.h" +#include "laige/result.h" + +namespace { + +// --------------------------------------------------------------------------- +// Small utilities +// --------------------------------------------------------------------------- + +bool isSpaceChar(char c) noexcept { + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; +} + +bool isIdentChar(unsigned char c) noexcept { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_'; +} + +// Operator characters that may follow `operator` in an operator name. +// `~` is handled separately (destructor form), `.` never appears in an +// operator name here (sizeof/member access is not a function name). +bool isOpChar(unsigned char c) noexcept { + switch (c) { + case '+': case '-': case '*': case '/': case '%': + case '^': case '&': case '|': case '!': case '<': case '>': + case '=': return true; + default: return false; + } +} + +// The full C++20 keyword set: used to reject a "name" that is actually a +// keyword (e.g. the `(` of a function-pointer type `void(int)` must not +// be mistaken for a parameter list, and a `requires` clause must not be +// taken for a function name). +bool isKeywordWord(const std::string& w) noexcept { + static const char* const kWords[] = { + "alignas", "alignof", "and", "and_eq", "asm", "auto", "bitand", "bitor", + "bool", "break", "case", "catch", "char", "char8_t", "char16_t", + "char32_t", "class", "concept", "const", "const_cast", "consteval", + "constexpr", "constinit", "continue", "co_await", "co_return", + "co_yield", "decltype", "default", "delete", "do", "double", + "dynamic_cast", "else", "enum", "explicit", "export", "extern", + "false", "float", "for", "friend", "goto", "if", "inline", "int", + "long", "mutable", "namespace", "new", "noexcept", "not", "not_eq", + "nullptr", "operator", "or", "or_eq", "private", "protected", + "public", "register", "reinterpret_cast", "requires", "return", + "short", "signed", "sizeof", "static", "static_assert", + "static_cast", "struct", "switch", "template", "this", + "thread_local", "throw", "true", "try", "typedef", "typeid", + "typename", "union", "unsigned", "using", "virtual", "void", + "volatile", "wchar_t", "while", "xor", "xor_eq", + }; + for (const char* const k : kWords) { + if (w == k) return true; + } + return false; +} + +// First-token statement keywords: a declaration-level candidate starting +// with one of these is a statement, not a declaration (no symbol). +// NOTE: `requires` is NOT here — a `requires(...)` clause after a +// template prefix is part of a declaration, not a statement. +bool isStatementKeyword(const std::string& w) noexcept { + return w == "static_assert" || w == "assert" || w == "if" || w == "for" || + w == "while" || w == "do" || w == "switch" || w == "return" || + w == "throw" || w == "case" || w == "break" || w == "continue" || + w == "new" || w == "delete" || w == "co_await" || + w == "co_return" || w == "co_yield" || w == "sizeof" || + w == "alignas" || w == "alignof" || w == "decltype"; +} + +// True when the word w starts at pos in s and is not followed by an +// identifier character (word boundary). +bool wordAt(const std::string& s, size_t pos, const std::string& w) noexcept { + if (pos + w.size() > s.size()) return false; + if (s.compare(pos, w.size(), w) != 0) return false; + return pos + w.size() >= s.size() || + !isIdentChar(static_cast(s[pos + w.size()])); +} + +// Banner characters (comment section decorations). +bool isBannerChar(char c) noexcept { + return c == '-' || c == '=' || c == '~' || c == '*'; +} + +// A banner line is either a pure run of banner characters, or a section +// header: a leading run of banner characters, text, and a trailing run of +// at least three (`// --- Named values (CORE-005) ---- ...`). +bool isBannerLine(const std::string& text) noexcept { + if (text.empty()) return false; + size_t lead = 0; + while (lead < text.size() && isBannerChar(text[lead])) ++lead; + size_t tail = 0; + while (tail < text.size() - lead && isBannerChar(text[text.size() - 1 - tail])) ++tail; + if (lead + tail == text.size()) return text.size() >= 3; + return lead >= 1 && tail >= 3; +} + +std::string trim(const std::string& s) { + size_t a = 0, b = s.size(); + while (a < b && isSpaceChar(s[a])) ++a; + while (b > a && isSpaceChar(s[b - 1])) --b; + return s.substr(a, b - a); +} + +// Collapse every whitespace run to one space; trim both ends. Used for +// signatures (multi-line declarations become one line). +std::string normalizeWs(const std::string& s) { + std::string out; + out.reserve(s.size()); + bool pending = false; + for (char c : s) { + if (isSpaceChar(c)) { + pending = true; + } else { + if (pending && !out.empty()) out += ' '; + pending = false; + out += c; + } + } + return trim(out); +} + +// Escape a string for the manifest JSON (ASCII-safe: control characters +// as \uXXXX, the common ones as two-character escapes; UTF-8 text above +// 0x7F passes through raw — the manifest is a UTF-8 document). +std::string jsonEscape(const std::string& s) { + std::string out; + out.reserve(s.size()); + for (unsigned char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (c < 0x20) { + static const char kHex[] = "0123456789abcdef"; + out += "\\u00"; + out += kHex[(c >> 4) & 0xF]; + out += kHex[c & 0xF]; + } else { + out += static_cast(c); + } + break; + } + } + return out; +} + +bool readFile(const std::string& path, std::string& out, + std::string& err) { + std::ifstream f(path, std::ios::binary); + if (!f) { + err = "cannot open " + path; + return false; + } + std::ostringstream ss; + ss << f.rdbuf(); + out = ss.str(); + if (out.find('\0') != std::string::npos) { + err = path + ": NUL byte in a text header (unsupported)"; + return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// Doc block -> manifest fields +// --------------------------------------------------------------------------- + +struct DocInfo { + std::string summary; // first non-empty paragraph ("" when none) + std::string budget; // first "@budget " line ("" when none) + bool experimental = false; // any "@experimental" line +}; + +bool isBudgetTagLine(const std::string& ln) noexcept { + static const char kTag[] = "@budget"; + if (ln.size() < sizeof(kTag) - 1) return false; + if (ln.compare(0, sizeof(kTag) - 1, kTag) != 0) return false; + return ln.size() == sizeof(kTag) - 1 || ln[sizeof(kTag) - 1] == ' '; +} + +bool isExperimentalTagLine(const std::string& ln) noexcept { + static const char kTag[] = "@experimental"; + if (ln.size() < sizeof(kTag) - 1) return false; + if (ln.compare(0, sizeof(kTag) - 1, kTag) != 0) return false; + return ln.size() == sizeof(kTag) - 1 || ln[sizeof(kTag) - 1] == ' '; +} + +// lines: comment texts in block order ("" = blank comment line); +// breaks: parallel, 1 where the line starts a new paragraph. +DocInfo docFromLines(const std::vector& lines, + const std::vector& breaks) { + DocInfo d; + size_t i = 0; + while (i < lines.size()) { + size_t j = i; + std::string para; + while (j < lines.size()) { + const std::string& ln = lines[j]; + if (!ln.empty()) { + if (isBudgetTagLine(ln)) { + if (d.budget.empty()) d.budget = normalizeWs(ln.substr(8)); + } else if (isExperimentalTagLine(ln)) { + d.experimental = true; + } else { + if (!para.empty()) para += ' '; + para += ln; + } + } + ++j; + if (j < lines.size() && breaks[j]) break; + } + if (d.summary.empty() && !para.empty()) d.summary = para; + i = j; + } + return d; +} + +// --------------------------------------------------------------------------- +// Symbols +// --------------------------------------------------------------------------- + +struct Symbol { + std::string kind; // class|struct|enum|function|method|constructor| + // destructor|variable|alias|enumerator|macro + std::string name; // fully qualified (macros: bare) + std::string header; // repo-relative path with '/' + int line = 0; // first line of the declaration + std::string signature; // normalized single line + std::string summary; // "" when absent + std::string budget; // "" when absent + bool experimental = false; +}; + +// --------------------------------------------------------------------------- +// Declaration classification (operates on normalized text, string-aware) +// --------------------------------------------------------------------------- + +// Strip a leading `template <...>` prefix (angle-balanced, string-aware). +std::string stripTemplatePrefix(const std::string& text) { + const std::string kT = "template"; + if (text.compare(0, kT.size(), kT) != 0) return text; + size_t i = kT.size(); + while (i < text.size() && isSpaceChar(text[i])) ++i; + if (i >= text.size() || text[i] != '<') return text; + int angle = 0; + bool inStr = false; + char q = 0; + for (; i < text.size(); ++i) { + char c = text[i]; + if (inStr) { + if (c == q) inStr = false; + continue; + } + if (c == '"' || c == '\'') { inStr = true; q = c; continue; } + if (c == '<') ++angle; + else if (c == '>') { + --angle; + if (angle == 0) return text.substr(i + 1); + } + } + return text; // unbalanced template list: leave as-is (fails downstream) +} + +// First word (identifier-character run) of text, skipping leading +// whitespace. `static_assert(...)` yields "static_assert"; `[[nodiscard]] +// foo` yields "" (the attribute is not a word). +std::string firstWord(const std::string& text) { + size_t i = 0; + while (i < text.size() && isSpaceChar(text[i])) ++i; + size_t j = i; + while (j < text.size() && isIdentChar(static_cast(text[j]))) ++j; + return text.substr(i, j - i); +} + +// Read the name token immediately before the '(' at parenPos in text. +// Forms: identifier, ~identifier (destructor), operator, +// qualified name (`A::b` — qualified set when the name carries a `::` +// prefix, i.e. an out-of-line member definition). +bool nameTokenBefore(const std::string& text, size_t parenPos, + std::string& name, bool& qualified) { + size_t i = parenPos; + while (i > 0 && isSpaceChar(text[i - 1])) --i; + if (i == 0) return false; + qualified = false; + unsigned char c0 = static_cast(text[i - 1]); + if (isOpChar(c0)) { + size_t j = i; + while (j > 0 && isOpChar(static_cast(text[j - 1]))) --j; + std::string op; + op.reserve(i - j); + for (size_t k = j; k < i; ++k) op += text[k]; // source order + size_t k2 = j; + while (k2 > 0 && isIdentChar(static_cast(text[k2 - 1]))) --k2; + if (k2 == j) return false; + std::string id = text.substr(k2, j - k2); + if (id != "operator") return false; + name = "operator" + op; + if (k2 >= 2 && text[k2 - 1] == ':' && text[k2 - 2] == ':') qualified = true; + return true; + } + if (isIdentChar(c0)) { + size_t j = i; + while (j > 0 && isIdentChar(static_cast(text[j - 1]))) --j; + std::string id = text.substr(j, i - j); + if (j > 0 && text[j - 1] == '~') { + id = "~" + id; + --j; + } + name = id; + if (j >= 2 && text[j - 1] == ':' && text[j - 2] == ':') qualified = true; + return true; + } + return false; +} + +// Find the first top-level '(' whose preceding name token is a real +// (non-keyword) function name. Returns its position in text. +bool findParamList(const std::string& text, size_t& paramPos) { + int paren = 0, brace = 0, bracket = 0; + bool inStr = false; + char q = 0; + for (size_t i = 0; i < text.size(); ++i) { + char c = text[i]; + if (inStr) { + if (c == q) inStr = false; + continue; + } + if (c == '"' || c == '\'') { inStr = true; q = c; continue; } + if (c == '(') { + if (paren == 0 && brace == 0 && bracket == 0) { + std::string nm; + bool qual = false; + if (nameTokenBefore(text, i, nm, qual) && !isKeywordWord(nm)) { + paramPos = i; + return true; + } + } + ++paren; + } else if (c == ')') { + if (paren > 0) --paren; + } else if (c == '[') { + ++bracket; + } else if (c == ']') { + if (bracket > 0) --bracket; + } else if (c == '{') { + ++brace; + } else if (c == '}') { + if (brace > 0) --brace; + } + } + return false; +} + +// Top-level '=' anywhere in [0, limit) (paren/brace/bracket depth 0). The +// '=' inside the '<=>', '<=', '>=' operators is not an initializer. +bool hasTopLevelEqualsBefore(const std::string& text, size_t limit) { + int paren = 0, brace = 0, bracket = 0; + bool inStr = false; + char q = 0; + for (size_t i = 0; i < limit && i < text.size(); ++i) { + char c = text[i]; + if (inStr) { + if (c == q) inStr = false; + continue; + } + if (c == '"' || c == '\'') { inStr = true; q = c; continue; } + if (c == '(') ++paren; + else if (c == ')') { if (paren > 0) --paren; } + else if (c == '[') ++bracket; + else if (c == ']') { if (bracket > 0) --bracket; } + else if (c == '{') ++brace; + else if (c == '}') { if (brace > 0) --brace; } + else if (c == '=' && paren == 0 && brace == 0 && bracket == 0) { + bool prevLt = i > 0 && text[i - 1] == '<'; + bool nextGt = i + 1 < text.size() && text[i + 1] == '>'; + if (prevLt || nextGt) continue; // part of '<=>', '<=', '>=' + // The '=' that IS the assignment-operator name (`operator=`, + // `operator+=`, ...) is not an initializer either. + size_t k = i; + while (k > 0 && isOpChar(static_cast(text[k - 1]))) --k; + size_t b = k; + while (b > 0 && isIdentChar(static_cast(text[b - 1]))) --b; + if (k - b == 8 && text.compare(b, 8, "operator") == 0) continue; + return true; + } + } + return false; +} + +// The variable name in `Type name = initializer` / `Type name{...}` / +// `Type state_[N]` / `alignas(T) std::byte data[N]`: the last identifier +// before the first top-level '=', '{', or '['. The text has no trailing +// ';'. The '=' of comparison operators and of operator names is not an +// initializer (it only matters after the cut, so this is cheap). +std::string variableNameIn(const std::string& text) { + int paren = 0, brace = 0, bracket = 0; + bool inStr = false; + char q = 0; + size_t cut = text.size(); + for (size_t i = 0; i < text.size(); ++i) { + char c = text[i]; + if (inStr) { + if (c == q) inStr = false; + continue; + } + if (c == '"' || c == '\'') { inStr = true; q = c; continue; } + if (c == '(') ++paren; + else if (c == ')') { if (paren > 0) --paren; } + else if (c == '[') { + if (paren == 0 && brace == 0 && bracket == 0) { + // The '[' of an array type after a template-id + // (`ElementSlot[]`) is a type suffix, not a bound. + bool afterGt = false; + size_t k = i; + while (k > 0 && isSpaceChar(text[k - 1])) --k; + if (k > 0 && text[k - 1] == '>') afterGt = true; + if (!afterGt) { cut = i; break; } + } + ++bracket; + } else if (c == ']') { + if (bracket > 0) --bracket; + } else if (c == '{') { + if (brace == 0 && paren == 0 && bracket == 0) { cut = i; break; } + ++brace; + } else if (c == '}') { + if (brace > 0) --brace; + } else if (c == '=' && paren == 0 && brace == 0 && bracket == 0) { + bool prevLt = i > 0 && text[i - 1] == '<'; + bool nextGt = i + 1 < text.size() && text[i + 1] == '>'; + if (prevLt || nextGt) continue; // '<=>', '<=', '>=' + size_t k = i; + while (k > 0 && isOpChar(static_cast(text[k - 1]))) --k; + size_t b = k; + while (b > 0 && isIdentChar(static_cast(text[b - 1]))) --b; + if (k - b == 8 && text.compare(b, 8, "operator") == 0) continue; + cut = i; + break; + } + } + size_t end = cut; + while (end > 0 && isSpaceChar(text[end - 1])) --end; + size_t start = end; + while (start > 0 && isIdentChar(static_cast(text[start - 1]))) --start; + return text.substr(start, end - start); +} + +// First identifier after the leading `using` keyword. +std::string aliasNameIn(const std::string& text) { + const std::string kU = "using"; + size_t i = 0; + while (i < text.size() && isSpaceChar(text[i])) ++i; + if (text.compare(i, kU.size(), kU) != 0) return ""; + i += kU.size(); + while (i < text.size() && isSpaceChar(text[i])) ++i; + size_t j = i; + while (j < text.size() && isIdentChar(static_cast(text[j]))) ++j; + return text.substr(i, j - i); +} + +// --------------------------------------------------------------------------- +// Header scanner +// --------------------------------------------------------------------------- + +struct Scope { + std::string name; + int kind = 0; // 0 namespace, 1 class/struct, 2 enum + int access = 2; // kind 1: 0 private, 1 protected, 2 public + int openBraceDepth = 0; // braceDepth_ value after the opening '{' +}; + +class FileScanner { + public: + FileScanner(const std::string& relHeader, std::vector& out) + : relHeader_(relHeader), out_(out) {} + + // Returns false on failure (header:line: message in error()). + bool scan(const std::string& src) { + src_ = &src; + pos_ = 0; + line_ = 1; + while (pos_ < src.size()) { + // Any fail() sets errMsg_ and returns to this loop without consuming + // the failing character: exit here instead of spinning. + if (!errMsg_.empty()) break; + char c = src[pos_]; + if (inString_) { + stepString(c); + continue; + } + if (c == '\n') { + endLine(); + continue; + } + if (isSpaceChar(c)) { + // Whitespace inside a candidate/enum segment is preserved (as a + // single space after normalizeWs); elsewhere it is skipped. + if (inCandidate_) candText_ += ' '; + if (inEnum_) enumSeg_ += ' '; + ++pos_; + continue; + } + if (c == '"' || c == '\'') { + inString_ = true; + stringQuote_ = c; + stringEsc_ = false; + markCode(); + if (inCandidate_) candText_ += c; + if (inEnum_) enumSeg_ += c; + ++pos_; + continue; + } + if (c == '/' && pos_ + 1 < src.size() && src[pos_ + 1] == '/') { + if (inBody_) { + skipToNewline(); + continue; + } + if (inCandidate_) { + candText_ += ' '; + skipToNewline(); + continue; + } + stepComment(); + continue; + } + if (c == '/' && pos_ + 1 < src.size() && src[pos_ + 1] == '*') { + fail("block comments are not supported by the laige-api scanner; " + "use // comments in public headers"); + return false; + } + if (inBody_) { + if (c == '{') { + ++bodyBrace_; + } else if (c == '}') { + --bodyBrace_; + if (bodyBrace_ == 0) inBody_ = false; + } + markCode(); + ++pos_; + continue; + } + if (inEnum_) { + stepEnum(c); + continue; + } + if (inCandidate_) { + stepCandidate(c); + continue; + } + stepTop(c); + } + if (!errMsg_.empty()) return false; + if (inCandidate_) { + fail("unterminated declaration starting at line " + + std::to_string(candLine_)); + return false; + } + if (inBody_) { + fail("unterminated function body (missing '}')"); + return false; + } + if (!scopes_.empty()) { + fail("unbalanced braces: '" + scopes_.back().name + "' never closed"); + return false; + } + return true; + } + + const std::string& error() const { return errMsg_; } + int errorLine() const { return errLine_; } + + private: + // ----------------------------------------------------------------------- + // State + // ----------------------------------------------------------------------- + + const std::string relHeader_; + std::vector& out_; + + const std::string* src_ = nullptr; + size_t pos_ = 0; + int line_ = 1; + std::string errMsg_; + int errLine_ = 0; + + // String literal state. + bool inString_ = false; + char stringQuote_ = 0; + bool stringEsc_ = false; + + // Scope bookkeeping. + std::vector scopes_; + int braceDepth_ = 0; + bool inDetail_ = false; + + // Doc block state (the last comment block not yet attached/discarded). + bool docActive_ = false; + bool docComplete_ = false; + std::vector docLines_; + std::vector docBreaks_; + + // Current-line state. + bool prevLineTrailing_ = false; + bool lineHasCode_ = false; + bool lineHasComment_ = false; + bool lineHasTrailing_ = false; + bool lineHasPreproc_ = false; + bool linePreprocTransparent_ = false; + + // Candidate (declaration) accumulation. + bool inCandidate_ = false; + std::string candText_; + int candLine_ = 0; + int candParen_ = 0; + int candBracket_ = 0; + int candBrace_ = 0; + bool candParamFound_ = false; + bool candIsTypeOpen_ = false; + bool candTemplateActive_ = false; + int candAngle_ = 0; + bool pendingFirstToken_ = false; + bool candRequiresClause_ = false; + int candRequiresParen_ = 0; + + // Function body skip. + bool inBody_ = false; + int bodyBrace_ = 0; + + // Enum body. + bool inEnum_ = false; + int enumParen_ = 0; + std::string enumSeg_; + bool enumSegStarted_ = false; + int enumSegLine_ = 0; + + // ----------------------------------------------------------------------- + // Basics + // ----------------------------------------------------------------------- + + const std::string& src() const { return *src_; } + + bool fail(const std::string& msg) { + if (errMsg_.empty()) { + errMsg_ = msg; + errLine_ = line_; + } + return false; + } + + void markCode() { lineHasCode_ = true; } + + void skipToNewline() { + while (pos_ < src().size() && src()[pos_] != '\n') ++pos_; + } + + // Consume whitespace on the current line (no newlines). + void skipWsSameLine() { + while (pos_ < src().size() && isSpaceChar(src()[pos_]) && + src()[pos_] != '\n') { + ++pos_; + } + } + + // A physical line ends (pos_ is at the '\n'). + void endLine() { + prevLineTrailing_ = lineHasTrailing_; + // A blank line (or a non-transparent directive line) finalizes an open + // doc block: the block stays pending, and the next comment line starts + // a fresh block that replaces it (the last block above a declaration + // wins). Comment lines and transparent directive lines do NOT + // finalize: a block may span blank *comment* lines and #if/#endif. + if (!inBody_ && docActive_ && !lineHasCode_ && !lineHasComment_ && + !(lineHasPreproc_ && linePreprocTransparent_)) { + docComplete_ = true; + } + if (inCandidate_) candText_ += ' '; + if (inEnum_) enumSeg_ += ' '; + lineHasCode_ = false; + lineHasComment_ = false; + lineHasTrailing_ = false; + lineHasPreproc_ = false; + linePreprocTransparent_ = false; + ++pos_; + ++line_; + } + + // String literal content (pos_ at a content character, inString_ true). + void stepString(char c) { + if (c == '\n') { + // A raw newline inside a string is malformed C++; close the string + // and let the normal newline bookkeeping run (loud downstream). + inString_ = false; + stringEsc_ = false; + return; // the main loop's endLine() runs next + } + if (stringEsc_) { + stringEsc_ = false; + } else if (c == '\\') { + stringEsc_ = true; + } else if (c == stringQuote_) { + inString_ = false; + stringEsc_ = false; + } + if (inCandidate_) candText_ += c; + if (inEnum_) enumSeg_ += c; + ++pos_; + } + + // A line of comments starts at pos_ (c == '/' and src_[pos_+1] == '/'). + void stepComment() { + size_t start = pos_ + 2; + size_t end = start; + while (end < src().size() && src()[end] != '\n') ++end; + std::string text = trim(src().substr(start, end - start)); + pos_ = end; // stop at the '\n' (handled by the main loop) + lineHasComment_ = true; + if (lineHasCode_) { + // A trailing comment on a code line (or the wrapped continuation of + // one): starts no doc block and extends none. + lineHasTrailing_ = true; + return; + } + if (isBannerLine(text)) { + // Banner: finalize (not discard) the current block. + if (docActive_) docComplete_ = true; + return; + } + if (docActive_ && !docComplete_) { + // Continuation of the open block: a new paragraph only after a blank + // comment line (already stored as ""). + docBreaks_.push_back(!docLines_.empty() && docLines_.back().empty() + ? 1 : 0); + docLines_.push_back(text); + return; + } + // Start a new block (replacing any completed pending block). + if (prevLineTrailing_) return; + docActive_ = true; + docComplete_ = false; + docLines_.clear(); + docBreaks_.clear(); + docLines_.push_back(text); + docBreaks_.push_back(1); + } + + // Attach the pending doc block to a symbol (consumes it either way). + DocInfo consumeDoc() { + DocInfo d; + if (docActive_) { + d = docFromLines(docLines_, docBreaks_); + docActive_ = false; + docComplete_ = false; + docLines_.clear(); + docBreaks_.clear(); + } + return d; + } + + // ----------------------------------------------------------------------- + // Scope bookkeeping + // ----------------------------------------------------------------------- + + void recomputeInDetail() { + inDetail_ = false; + for (const Scope& s : scopes_) { + if (s.kind == 0 && s.name == "detail") { + inDetail_ = true; + break; + } + } + } + + void closeTopScope() { + if (scopes_.empty()) { + fail("unbalanced '}'"); + return; + } + scopes_.pop_back(); + recomputeInDetail(); + docActive_ = false; // no symbol attaches across a closing brace + } + + // The nearest enclosing class/struct scope (empty when none). + const Scope* classScope() const { + for (auto it = scopes_.rbegin(); it != scopes_.rend(); ++it) { + if (it->kind == 1) return &*it; + } + return nullptr; + } + + // Public = namespace scope, or inside a class with current access + // public, and not inside a `detail` namespace. + bool isEligible() const { + if (inDetail_) return false; + const Scope* c = classScope(); + return c == nullptr || c->access == 2; + } + + std::string qualifiedBase() const { + std::string q; + for (const Scope& s : scopes_) { + if (!q.empty()) q += "::"; + q += s.name; + } + return q; + } + + void emit(const std::string& kind, const std::string& localName, + const std::string& signature, int line, const DocInfo& doc) { + if (!isEligible()) return; + Symbol sym; + sym.kind = kind; + sym.name = + kind == "macro" ? localName : qualifiedBase() + "::" + localName; + sym.header = relHeader_; + sym.line = line; + sym.signature = signature; + sym.summary = doc.summary; + sym.budget = doc.budget; + sym.experimental = doc.experimental; + out_.push_back(std::move(sym)); + } + + // ----------------------------------------------------------------------- + // Top level + // ----------------------------------------------------------------------- + + void stepTop(char c) { + if (c == '#') { + if (lineHasCode_) { + fail("preprocessor directive must start a line"); + return; + } + stepPreproc(); + return; + } + if (c == '}') { + markCode(); + ++pos_; + --braceDepth_; + if (scopes_.empty() || braceDepth_ < 0) { + fail("unbalanced '}'"); + return; + } + // A nested `namespace a::b {` pushes several scopes that share the + // single opening brace: close them all here. + while (!scopes_.empty() && braceDepth_ < scopes_.back().openBraceDepth) { + closeTopScope(); + } + skipWsSameLine(); + if (pos_ < src().size() && src()[pos_] == ';') ++pos_; + return; + } + if (c == ';') { + markCode(); + ++pos_; + return; + } + if (tryAccessSpecifier()) return; + markCode(); + beginCandidate(); + stepCandidate(c); + } + + // Consume a `public:` / `private:` / `protected:` line inside a class. + bool tryAccessSpecifier() { + if (lineHasCode_) return false; + const Scope* c = classScope(); + if (c == nullptr) return false; + std::string tok; + size_t i = pos_; + const std::string& s = src(); + while (i < s.size() && s[i] != '\n' && + !(s[i] == '/' && i + 1 < s.size() && s[i + 1] == '/')) { + if (isSpaceChar(s[i])) { + ++i; + } else { + tok += s[i]; + ++i; + } + } + if (tok == "public:" || tok == "private:" || tok == "protected:") { + skipToNewline(); + markCode(); + docActive_ = false; // no symbol attaches across an access specifier + scopes_.back().access = + tok == "public:" ? 2 : (tok == "private:" ? 0 : 1); + return true; + } + return false; + } + + // ----------------------------------------------------------------------- + // Preprocessor directives (line starts at pos_, c == '#') + // ----------------------------------------------------------------------- + + void stepPreproc() { + markCode(); + lineHasPreproc_ = true; + // Read the logical directive: physical lines joined at backslash + // continuations. Continuation lines carry no per-line state of their + // own (they are part of this directive). + std::string logical; + size_t end = 0; + while (true) { + end = pos_; + while (end < src().size() && src()[end] != '\n') ++end; + std::string phys = trim(src().substr(pos_, end - pos_)); + bool cont = !phys.empty() && phys.back() == '\\'; + if (cont) phys.pop_back(); + if (!logical.empty()) logical += ' '; + logical += phys; + if (!cont) { + pos_ = end; // stop at the '\n' (or EOF); the main loop ends the line + break; + } + if (end >= src().size()) { + pos_ = src().size(); // continuation at EOF: malformed (loud later) + break; + } + pos_ = end + 1; // over the '\n' + ++line_; + lineHasCode_ = false; + lineHasComment_ = false; + lineHasTrailing_ = false; + } + + // Classify: first token after '#'. + std::string body = logical; + if (!body.empty() && body[0] == '#') body = body.substr(1); + std::string tok = firstWord(body); + + bool transparent = + tok == "if" || tok == "ifdef" || tok == "ifndef" || tok == "elif" || + tok == "else" || tok == "endif"; + if (tok == "pragma") { + size_t i = 0; + while (i < body.size() && isSpaceChar(body[i])) ++i; + if (body.compare(i, 6, "pragma") == 0) i += 6; + while (i < body.size() && isSpaceChar(body[i])) ++i; + if (body.compare(i, 3, "GCC") == 0 || body.compare(i, 5, "clang") == 0) { + // `#pragma [GCC|clang] diagnostic ...` (sim_math.h pins its + // -Wfloat-equal suppression this way; the pinned-math ADR 0002 + // documents it). Transparent: it neither discards nor creates + // docs. + transparent = true; + } + } + linePreprocTransparent_ = transparent; + if (tok == "define") { + // A #define is a symbol: it consumes the doc block itself (before + // the non-transparent discard below). + if (classScope() != nullptr) { + fail("a #define inside a class body is not supported by the " + "laige-api scanner"); + return; + } + handleMacro(logical); + return; + } + if (!transparent) { + docActive_ = false; // non-transparent directive: no doc across it + } + } + + // Emit a #define macro symbol (namespace scope). + void handleMacro(const std::string& logical) { + std::string body = logical; + if (!body.empty() && body[0] == '#') body = body.substr(1); + size_t i = 0; + while (i < body.size() && isSpaceChar(body[i])) ++i; + if (body.compare(i, 6, "define") != 0) return; + i += 6; + while (i < body.size() && isSpaceChar(body[i])) ++i; + size_t start = i; + while (i < body.size() && isIdentChar(static_cast(body[i]))) ++i; + if (i == start) { + fail("malformed #define (missing macro name)"); + return; + } + std::string name = body.substr(start, i - start); + while (i < body.size() && isSpaceChar(body[i])) ++i; + std::string sig; + if (i < body.size() && body[i] == '(') { + // Function-like: capture the balanced parameter list. + size_t j = i + 1; + int paren = 1; + bool inStr = false; + char q = 0; + for (; j < body.size(); ++j) { + char c = body[j]; + if (inStr) { + if (c == q) inStr = false; + continue; + } + if (c == '"' || c == '\'') { inStr = true; q = c; continue; } + if (c == '(') ++paren; + else if (c == ')') { + --paren; + if (paren == 0) break; + } + } + if (j >= body.size()) { + fail("malformed #define (unbalanced parameter list for " + name + ")"); + return; + } + sig = "#define " + name + "(" + normalizeWs(body.substr(i + 1, j - i - 1)) + ")"; + } else { + std::string rest = trim(body.substr(i)); + sig = "#define " + name + (rest.empty() ? "" : " " + rest); + } + DocInfo doc = consumeDoc(); + emit("macro", name, sig, line_, doc); + } + + // ----------------------------------------------------------------------- + // Candidate (declaration) accumulation + // ----------------------------------------------------------------------- + + void beginCandidate() { + candLine_ = line_; + candText_.clear(); + candParen_ = 0; + candBracket_ = 0; + candBrace_ = 0; + candParamFound_ = false; + candIsTypeOpen_ = false; + candTemplateActive_ = false; + candAngle_ = 0; + pendingFirstToken_ = false; + candRequiresClause_ = false; + candRequiresParen_ = 0; + inCandidate_ = true; + // First token at the candidate start: a type keyword opens a type + // declaration (its '{' starts the body/scope, not a brace-init); a + // `template` keyword opens the angle list (the type keyword is + // recognized after the '>' closes). + const std::string& s = src(); + size_t i = pos_; + if (wordAt(s, i, "class") || wordAt(s, i, "struct") || + wordAt(s, i, "enum") || wordAt(s, i, "namespace")) { + candIsTypeOpen_ = true; + } else if (wordAt(s, i, "template") && + (i + 8 >= s.size() || s[i + 8] == '<' || + isSpaceChar(s[i + 8]))) { + candTemplateActive_ = true; + } + } + + void stepCandidate(char c) { + candText_ += c; + // NOTE: this block must run before the template-angle update below, so + // that a pendingFirstToken_ set on this character (the template's '>') + // is evaluated on the NEXT character, not consumed by it. + if (pendingFirstToken_) { + const std::string& s = src(); + if (candRequiresClause_) { + // Inside a `requires(...)` clause (constrained declaration): + // keep waiting for the type keyword after the clause closes. + if (c == '(') { + ++candRequiresParen_; + } else if (c == ')') { + if (candRequiresParen_ > 0) --candRequiresParen_; + if (candRequiresParen_ == 0) candRequiresClause_ = false; + } + } else if (wordAt(s, pos_, "class") || wordAt(s, pos_, "struct") || + wordAt(s, pos_, "enum") || wordAt(s, pos_, "namespace")) { + candIsTypeOpen_ = true; + pendingFirstToken_ = false; + } else if (wordAt(s, pos_, "requires") && pos_ + 9 < s.size() && + s[pos_ + 8] == '(') { + candRequiresClause_ = true; + candRequiresParen_ = 0; + } else { + pendingFirstToken_ = false; + } + } + if (candTemplateActive_) { + if (c == '<') ++candAngle_; + else if (c == '>') { + --candAngle_; + if (candAngle_ == 0) { + candTemplateActive_ = false; + pendingFirstToken_ = true; + } + } + } + ++pos_; // consume the character (the finish* paths resume past it) + switch (c) { + case '(': + if (candParen_ == 0 && candBrace_ == 0 && candBracket_ == 0 && + !candParamFound_ && !candTemplateActive_) { + std::string nm; + bool qual = false; + if (nameTokenBefore(candText_, candText_.size() - 1, nm, qual) && + !isKeywordWord(nm)) { + candParamFound_ = true; + } + } + ++candParen_; + break; + case ')': + if (candParen_ > 0) --candParen_; + break; + case '[': + ++candBracket_; + break; + case ']': + if (candBracket_ > 0) --candBracket_; + break; + case '{': + if (candBrace_ == 0 && candParen_ == 0 && candBracket_ == 0 && + !candTemplateActive_) { + if (candIsTypeOpen_) { + finishTypeOpen(); + return; + } + if (candParamFound_) { + finishFunctionBody(); + return; + } + } + ++candBrace_; + break; + case '}': + if (candBrace_ > 0) --candBrace_; + break; + case ';': + if (candParen_ == 0 && candBrace_ == 0 && candBracket_ == 0) { + finishDeclaration(); + return; + } + break; + default: + break; + } + } + + void endCandidateState() { + inCandidate_ = false; + candParen_ = candBracket_ = candBrace_ = 0; + candParamFound_ = false; + candIsTypeOpen_ = false; + candTemplateActive_ = false; + candAngle_ = 0; + pendingFirstToken_ = false; + candRequiresClause_ = false; + candRequiresParen_ = 0; + } + + // `namespace a::b {` / `class X ... {` / `struct ... {` / `enum ... {` + void finishTypeOpen() { + std::string header = candText_; + header.pop_back(); // the '{' + std::string text = normalizeWs(header); + std::string rest = stripTemplatePrefix(text); + std::string ft = firstWord(rest); + // Offset of the keyword in rest (firstWord() skipped leading space). + size_t kw = 0; + while (kw < rest.size() && isSpaceChar(rest[kw])) ++kw; + endCandidateState(); + + if (ft == "namespace") { + std::string name = trim(rest.substr(kw + ft.size())); + // Split on '::'; every part must be a plain (non-keyword) + // identifier. All parts share the single opening brace. + std::vector parts; + bool ok = !name.empty(); + size_t a = 0; + while (ok) { + size_t b = name.find("::", a); + std::string part = + (b == std::string::npos) ? name.substr(a) : name.substr(a, b - a); + ok = !part.empty() && !isKeywordWord(part); + for (char ch : part) { + if (!isIdentChar(static_cast(ch))) { + ok = false; + break; + } + } + parts.push_back(part); + if (b == std::string::npos) break; + a = b + 2; + } + if (!ok || parts.empty()) { + fail("cannot parse namespace name in: " + text); + return; + } + int depth = ++braceDepth_; + for (const std::string& p : parts) { + Scope sc; + sc.name = p; + sc.kind = 0; + sc.openBraceDepth = depth; + scopes_.push_back(std::move(sc)); + } + recomputeInDetail(); + docActive_ = false; // a namespace header never carries a symbol doc + return; + } + + if (ft == "class" || ft == "struct" || ft == "enum") { + // Name: first identifier after the keywords (`enum class X` skips + // the second `class`). + std::string after = rest.substr(kw + ft.size()); + size_t i = 0; + while (i < after.size() && isSpaceChar(after[i])) ++i; + if (ft == "enum" && wordAt(after, i, "class")) { + i += 5; + while (i < after.size() && isSpaceChar(after[i])) ++i; + } + size_t j = i; + while (j < after.size() && isIdentChar(static_cast(after[j]))) ++j; + std::string name = after.substr(i, j - i); + if (name.empty() || isKeywordWord(name)) { + fail("cannot parse type name in: " + text); + return; + } + DocInfo doc = consumeDoc(); + emit(ft == "enum" ? "enum" : ft, name, text, candLine_, doc); + + Scope sc; + sc.name = name; + sc.kind = ft == "enum" ? 2 : 1; + sc.access = (ft == "struct") ? 2 : 0; // struct=public, class=private + sc.openBraceDepth = ++braceDepth_; + scopes_.push_back(std::move(sc)); + + if (ft == "enum") { + inEnum_ = true; + enumSeg_.clear(); + enumSegStarted_ = false; + enumParen_ = 0; + enumSegLine_ = 0; + } + return; + } + + fail("unrecognized type declaration: " + text); + } + + // A function-like declaration opening its body at '{'. + void finishFunctionBody() { + std::string header = candText_; + header.pop_back(); // the '{' + std::string text = normalizeWs(header); + DocInfo doc = consumeDoc(); + classifyFunction(text, doc); + endCandidateState(); + inBody_ = true; + bodyBrace_ = 1; // the body's own '{' + } + + // A ';' -terminated declaration: alias / typedef / statement / function + // / variable / forward declaration. + void finishDeclaration() { + std::string header = candText_; + header.pop_back(); // the ';' + std::string text = normalizeWs(header); + endCandidateState(); + classifyDeclaration(text); + } + + // Shared function/method/constructor/destructor classification for a + // declaration whose parameter list was found (text: normalized, no + // trailing ';' or '{'). + void classifyFunction(const std::string& text, const DocInfo& doc) { + std::string rest = stripTemplatePrefix(text); + std::string ft = firstWord(rest); + if (ft == "using" || isStatementKeyword(ft) || ft == "class" || + ft == "struct" || ft == "enum" || ft == "namespace" || + ft == "friend") { + return; // no symbol (the doc is consumed by the caller either way) + } + size_t ppos = 0; + if (!findParamList(rest, ppos)) { + fail("cannot find parameter list in function declaration: " + text); + return; + } + std::string pname; + bool qualified = false; + if (!nameTokenBefore(rest, ppos, pname, qualified)) { + fail("cannot determine function name in: " + text); + return; + } + if (qualified) { + // An out-of-line member definition (`Prng::seedState(...)` at + // namespace scope): the in-class declaration is the canonical + // manifest entry; the definition adds no new symbol. + return; + } + std::string unq = pname; + size_t qpos = unq.rfind("::"); + if (qpos != std::string::npos) unq = unq.substr(qpos + 2); + std::string kind; + if (!pname.empty() && pname[0] == '~') { + kind = "destructor"; + } else if (!qualified) { + const Scope* c = classScope(); + if (c != nullptr && c->name == unq) kind = "constructor"; + else kind = (c != nullptr) ? "method" : "function"; + } else { + kind = (classScope() != nullptr) ? "method" : "function"; + } + emit(kind, pname, text, candLine_, doc); + } + + void classifyDeclaration(const std::string& text) { + std::string rest = stripTemplatePrefix(text); + std::string ft = firstWord(rest); + DocInfo doc = consumeDoc(); + if (ft == "using") { + std::string nm = aliasNameIn(rest); + if (nm.empty() || isKeywordWord(nm)) { + fail("cannot determine alias name in: " + text); + return; + } + emit("alias", nm, text, candLine_, doc); + return; + } + if (ft == "typedef") { + // `typedef Type Name;`: the name is the last identifier. + std::string nm = variableNameIn(text); + if (nm.empty() || isKeywordWord(nm)) { + fail("cannot determine typedef name in: " + text); + return; + } + emit("alias", nm, text, candLine_, doc); + return; + } + if (isStatementKeyword(ft) || ft == "class" || ft == "struct" || + ft == "enum" || ft == "namespace" || ft == "friend") { + return; // statement or forward declaration: no symbol + } + size_t ppos = 0; + if (findParamList(rest, ppos) && + !hasTopLevelEqualsBefore(rest, ppos)) { + classifyFunction(text, doc); + return; + } + // A variable (or a function-pointer type with '=' before the param + // list, e.g. `Type name = makeFn(...)`). + std::string nm = variableNameIn(text); + if (nm.empty()) { + fail("cannot determine variable name in: " + text); + return; + } + emit("variable", nm, text, candLine_, doc); + } + + // ----------------------------------------------------------------------- + // Enum body + // ----------------------------------------------------------------------- + + void stepEnum(char c) { + markCode(); + if (c == '}') { + finalizeEnumerator(); + inEnum_ = false; + ++pos_; + --braceDepth_; + if (scopes_.empty() || braceDepth_ < 0) { + fail("unbalanced '}'"); + return; + } + while (!scopes_.empty() && braceDepth_ < scopes_.back().openBraceDepth) { + closeTopScope(); + } + skipWsSameLine(); + if (pos_ < src().size() && src()[pos_] == ';') ++pos_; + return; + } + ++pos_; // consume the character (the '}' branch above advances itself) + if (c == '(') { + ++enumParen_; + } else if (c == ')') { + if (enumParen_ > 0) --enumParen_; + } else if (c == ',' && enumParen_ == 0) { + finalizeEnumerator(); + enumSegStarted_ = false; + return; + } else if (!enumSegStarted_ && !isSpaceChar(c)) { + // First non-blank character of an enumerator segment: its line is + // the enumerator's line (the segment starts empty/blank, so the + // 'empty' test alone would miss segments preceded by whitespace). + enumSegStarted_ = true; + enumSegLine_ = line_; + } + enumSeg_ += c; + } + + void finalizeEnumerator() { + std::string seg = normalizeWs(enumSeg_); + // A space may precede the separating comma (`Mean , Min`); fold it. + std::string folded; + folded.reserve(seg.size()); + for (size_t i = 0; i < seg.size(); ++i) { + if (seg[i] == ' ' && i + 1 < seg.size() && seg[i + 1] == ',') continue; + folded += seg[i]; + } + seg = std::move(folded); + enumSeg_.clear(); + if (seg.empty()) return; + // Name: the identifier before the first top-level '=' (if any). + std::string name = seg; + int paren = 0; + for (size_t i = 0; i < seg.size(); ++i) { + char c = seg[i]; + if (c == '(') ++paren; + else if (c == ')') { if (paren > 0) --paren; } + else if (c == '=' && paren == 0) { + name = trim(seg.substr(0, i)); + break; + } + } + bool ok = !name.empty() && !isKeywordWord(name); + for (char ch : name) { + if (!isIdentChar(static_cast(ch))) { + ok = false; + break; + } + } + if (!ok) { + fail("unsupported enum initializer: " + seg); + return; + } + DocInfo doc = consumeDoc(); + emit("enumerator", name, seg, enumSegLine_, doc); + } +}; + +// --------------------------------------------------------------------------- +// Header discovery +// --------------------------------------------------------------------------- + +// PRD §10.1 module map, bottom to top — mirrors MODULE_STACK in +// tools/laige-include-lint; update both in one change if the PRD changes +// the map (CORE-006). +const char* const kModules[] = { + "laige-core", "laige-sim", "laige-render", "laige-assets", "laige-net", + "laige-server", "laige-script", "laige-editor", "laige-sample", +}; + +// Collect the public headers: every .h under src//include +// (recursive). Returns false with err set on I/O trouble (CORE-008). +bool discoverHeaders(const std::string& root, + std::vector& headers, std::string& err) { + headers.clear(); + namespace fs = std::filesystem; + std::error_code ec; + if (!fs::is_directory(fs::path(root), ec)) { + err = "root is not a directory: " + root; + return false; + } + for (const char* const m : kModules) { + fs::path inc = fs::path(root) / "src" / m / "include"; + if (!fs::is_directory(inc, ec)) continue; + fs::recursive_directory_iterator it(inc, ec), end; + if (ec) { + err = "cannot iterate " + inc.generic_string() + ": " + ec.message(); + return false; + } + for (; it != end; it.increment(ec)) { + if (ec) { + err = "cannot iterate " + inc.generic_string() + ": " + ec.message(); + return false; + } + if (!it->is_regular_file(ec)) continue; + if (it->path().extension() != ".h") continue; + fs::path rel = fs::relative(it->path(), root, ec); + if (ec) { + err = "cannot relativize " + it->path().generic_string() + ": " + + ec.message(); + return false; + } + headers.push_back(rel.generic_string()); + } + } + std::sort(headers.begin(), headers.end()); + return true; +} + +// --------------------------------------------------------------------------- +// Manifest serialization (deterministic, NFR-13.4) +// --------------------------------------------------------------------------- + +std::string serializeManifest(const std::vector& headers, + const std::vector& symbols) { + std::string out; + out += "{\n"; + out += " \"version\": 1,\n"; + out += " \"generatedBy\": \"laige-api\",\n"; + out += " \"headers\": ["; + for (size_t i = 0; i < headers.size(); ++i) { + out += std::string(i == 0 ? "\n " : ",\n ") + "\"" + jsonEscape(headers[i]) + "\""; + } + if (!headers.empty()) out += "\n "; + out += "],\n"; + out += " \"symbols\": [\n"; + for (size_t i = 0; i < symbols.size(); ++i) { + const Symbol& s = symbols[i]; + out += " {\"name\": \"" + jsonEscape(s.name) + "\""; + out += ", \"kind\": \"" + jsonEscape(s.kind) + "\""; + out += ", \"header\": \"" + jsonEscape(s.header) + "\""; + out += ", \"line\": " + std::to_string(s.line); + out += ", \"signature\": \"" + jsonEscape(s.signature) + "\""; + out += ", \"summary\": " + + (s.summary.empty() ? "null" : "\"" + jsonEscape(s.summary) + "\""); + out += ", \"budget\": " + + (s.budget.empty() ? "null" : "\"" + jsonEscape(s.budget) + "\""); + out += ", \"experimental\": " + std::string(s.experimental ? "true" : "false"); + out += "}"; + if (i + 1 < symbols.size()) out += ","; + out += "\n"; + } + out += " ]\n"; + out += "}\n"; + return out; +} + +// --------------------------------------------------------------------------- +// --check: compare against the checked-in manifest +// --------------------------------------------------------------------------- + +struct OldSymbol { + std::string name, kind, header, signature, summary, budget; + int line = 0; + bool experimental = false; +}; + +// Parse a checked-in manifest and collect its symbol entries. +bool parseOldManifest(const std::string& text, std::vector& out, + std::string& err) { + laige::Result parsed = laige::parseJson(text); + if (parsed.isError()) { + err = std::string("checked-in manifest is not valid JSON: ") + + laige::errorText(parsed.error()); + return false; + } + const laige::JsonValue& root = parsed.value(); + const laige::JsonValue* syms = root.findMember("symbols"); + if (syms == nullptr || !syms->isArray()) { + err = "checked-in manifest has no \"symbols\" array"; + return false; + } + for (const laige::JsonValue& el : syms->asArray()) { + OldSymbol os; + if (const laige::JsonValue* v = el.findMember("name")) { + if (v->isString()) os.name = std::string(v->asString()); + } + if (const laige::JsonValue* v = el.findMember("kind")) { + if (v->isString()) os.kind = std::string(v->asString()); + } + if (const laige::JsonValue* v = el.findMember("header")) { + if (v->isString()) os.header = std::string(v->asString()); + } + if (const laige::JsonValue* v = el.findMember("line")) { + if (v->isNumber()) os.line = static_cast(v->asNumber()); + } + if (const laige::JsonValue* v = el.findMember("signature")) { + if (v->isString()) os.signature = std::string(v->asString()); + } + if (const laige::JsonValue* v = el.findMember("summary")) { + if (v->isString()) os.summary = std::string(v->asString()); + } + if (const laige::JsonValue* v = el.findMember("budget")) { + if (v->isString()) os.budget = std::string(v->asString()); + } + if (const laige::JsonValue* v = el.findMember("experimental")) { + if (v->isBool()) os.experimental = v->asBool(); + } + out.push_back(std::move(os)); + } + return true; +} + +int runCheck(const std::string& checkFile, const std::string& manifestText, + const std::vector& symbols) { + std::string oldText; + std::string err; + if (!readFile(checkFile, oldText, err)) { + std::cout << "laige-api: error: " << err << std::endl; + return 2; + } + if (oldText == manifestText) { + std::cout << "laige-api: OK — " << checkFile + << " is up to date (" << symbols.size() << " symbol(s) from the " + << "current public headers)" << std::endl; + return 0; + } + std::vector old; + if (!parseOldManifest(oldText, old, err)) { + std::cout << "laige-api: error: " << err << std::endl; + return 2; + } + // Symbol-level diff (the byte mismatch is already established). + auto keyOf = [](const std::string& n, const std::string& h, int l) { + return n + "\x1f" + h + "\x1f" + std::to_string(l); + }; + std::vector> oldMap; + for (const OldSymbol& os : old) { + oldMap.push_back({keyOf(os.name, os.header, os.line), &os}); + } + std::vector newKeys; + for (const Symbol& s : symbols) { + newKeys.push_back(keyOf(s.name, s.header, s.line)); + } + std::vector added, removed, changed; + for (const std::string& k : newKeys) { + bool found = false; + for (const auto& p : oldMap) { + if (p.first == k) { found = true; break; } + } + if (!found) added.push_back(k); + } + for (const auto& p : oldMap) { + bool found = false; + for (const std::string& k : newKeys) { + if (k == p.first) { found = true; break; } + } + if (!found) removed.push_back(p.first); + } + // Changed: same key, any field differs. + for (const Symbol& s : symbols) { + std::string k = keyOf(s.name, s.header, s.line); + for (const auto& p : oldMap) { + if (p.first != k) continue; + const OldSymbol& os = *p.second; + if (os.kind != s.kind || os.signature != s.signature || + os.summary != s.summary || os.budget != s.budget || + os.experimental != s.experimental) { + std::vector f; + if (os.kind != s.kind) f.push_back("kind"); + if (os.signature != s.signature) f.push_back("signature"); + if (os.summary != s.summary) f.push_back("summary"); + if (os.budget != s.budget) f.push_back("budget"); + if (os.experimental != s.experimental) f.push_back("experimental"); + std::string fields; + for (size_t i = 0; i < f.size(); ++i) { + if (i) fields += ", "; + fields += f[i]; + } + size_t sep = k.find('\x1f'); + std::string nm = (sep == std::string::npos) ? k : k.substr(0, sep); + changed.push_back(nm + " (" + fields + ")"); + } + break; + } + } + std::sort(added.begin(), added.end()); + std::sort(removed.begin(), removed.end()); + std::sort(changed.begin(), changed.end()); + int total = static_cast(added.size() + removed.size() + changed.size()); + std::cout << "laige-api: ERROR — " << checkFile << " is stale (" << total + << " difference(s) vs the regenerated manifest)" << std::endl; + int shown = 0; + for (const std::string& s : added) { + size_t sep = s.find('\x1f'); + std::string name = (sep == std::string::npos) ? s : s.substr(0, sep); + std::cout << " + added: " << name << std::endl; + if (++shown >= 20) break; + } + for (const std::string& s : removed) { + size_t sep = s.find('\x1f'); + std::string name = (sep == std::string::npos) ? s : s.substr(0, sep); + std::cout << " - removed: " << name << std::endl; + if (++shown >= 20) break; + } + for (const std::string& s : changed) { + std::cout << " ~ changed: " << s << std::endl; + if (++shown >= 20) break; + } + if (total > shown) { + std::cout << " ... and " << (total - shown) << " more" << std::endl; + } + std::cout << "laige-api: regenerate with: ./build/bin/laige-api-scanner " + "--root . --out laige-api.json" << std::endl; + return 1; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +void printUsage(std::ostream& os) { + os << "Usage: laige-api-scanner --root REPO_ROOT (--out FILE | --check FILE)\n" + << "\n" + << " --root REPO_ROOT repository root (default: \".\")\n" + << " --out FILE write the regenerated manifest to FILE\n" + << " --check FILE compare the regenerated manifest with FILE:\n" + << " exit 0 if byte-identical, exit 1 if stale\n" + << "\n" + << "Exit codes: 0 = OK · 1 = stale manifest · 2 = error\n"; +} + +} // namespace + +int main(int argc, char** argv) { + std::string root = "."; + std::string outPath; + std::string checkPath; + for (int i = 1; i < argc; ++i) { + std::string a = argv[i]; + if (a == "--root") { + if (i + 1 >= argc) { printUsage(std::cerr); return 2; } + root = argv[++i]; + } else if (a == "--out") { + if (i + 1 >= argc) { printUsage(std::cerr); return 2; } + outPath = argv[++i]; + } else if (a == "--check") { + if (i + 1 >= argc) { printUsage(std::cerr); return 2; } + checkPath = argv[++i]; + } else if (a == "--help" || a == "-h") { + printUsage(std::cout); + return 0; + } else { + std::cerr << "laige-api: error: unknown argument '" << a << "'" + << std::endl; + printUsage(std::cerr); + return 2; + } + } + bool hasOut = !outPath.empty(); + bool hasCheck = !checkPath.empty(); + if (hasOut == hasCheck) { + std::cerr << "laige-api: error: exactly one of --out / --check is required" + << std::endl; + printUsage(std::cerr); + return 2; + } + + std::vector headers; + std::string err; + if (!discoverHeaders(root, headers, err)) { + std::cout << "laige-api: error: " << err << std::endl; + return 2; + } + + std::vector symbols; + for (const std::string& h : headers) { + std::string text; + std::string path = root == "." ? h : root + "/" + h; + if (!readFile(path, text, err)) { + std::cout << "laige-api: error: " << err << std::endl; + return 2; + } + FileScanner scanner(h, symbols); + if (!scanner.scan(text)) { + std::cout << "laige-api: error: " << h << ":" << scanner.errorLine() + << ": " << scanner.error() << std::endl; + return 2; + } + } + + std::string manifest = serializeManifest(headers, symbols); + + if (hasOut) { + std::ofstream f(outPath, std::ios::binary | std::ios::trunc); + if (!f) { + std::cout << "laige-api: error: cannot write " << outPath << std::endl; + return 2; + } + f << manifest; + f.close(); + if (!f) { + std::cout << "laige-api: error: write failed for " << outPath << std::endl; + return 2; + } + std::cout << "laige-api: wrote " << outPath << " (" << symbols.size() + << " symbol(s) from " << headers.size() << " header(s))" + << std::endl; + return 0; + } + return runCheck(checkPath, manifest, symbols); +}