diff --git a/docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md b/docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md index d457e4a..5f0139f 100644 --- a/docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md +++ b/docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md @@ -45,3 +45,43 @@ - handoff: `fesa_solver`, `fesa_unit_tests`, `fesa_tests`, normalized `Fesa::MKL`, `Fesa::TBB`, `Fesa::HDF5`, and `solverVersion()` are available to Step 8. + +## Step 8 — core-diagnostics + +- task_id: `TASK-08` +- status: `completed` +- changed_files: `include/fesa/core/source_identity.hpp`, + `include/fesa/core/diagnostic.hpp`, `include/fesa/core/status.hpp`, + `src/fesa/core/diagnostic.cpp`, `src/fesa/core/status.cpp`, + `src/fesa/CMakeLists.txt`, `tests/CMakeLists.txt`, + `tests/unit/core/source_identity_test.cpp`, + `tests/unit/core/diagnostic_test.cpp`, `tests/unit/core/status_test.cpp`, + `docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md` +- requirement_ids: `FESA-REQ-LS3DEB-033`, `FESA-REQ-LS3DEB-034` +- test_ids: `T08-CORE-001`, `T08-CORE-002`, `T08-CORE-003` + +| stage | exact command | exit_code | expected_or_observed_result | evidence_tail | +| --- | --- | ---: | --- | --- | +| RED | `cmake --build .harness/build --config Debug --target fesa_tests` | 1 | Tests are registered before production and fail for the three missing core headers | MSVC C1083 for `fesa/core/diagnostic.hpp`, `fesa/core/source_identity.hpp`, and `fesa/core/status.hpp` | +| GREEN-build | `cmake --build .harness/build --config Debug --target fesa_tests` | 0 | Minimal core implementation and all test translation units build | `diagnostic.cpp`, `status.cpp`, and three core tests compile; `fesa_unit_tests.exe` links | +| GREEN-test | `ctest --test-dir .harness/build -C Debug -R CoreDiagnostics --output-on-failure` | 0 | Identity, deterministic ordering, and Result exclusivity pass | 3/3 `CoreDiagnostics` tests passed | +| VERIFY-configure | `cmake -S . -B .harness/build -A x64 -DFESA_GTEST_SOURCE_DIR=C:/git/googletest "-DMKL_DIR=C:/Program Files (x86)/Intel/oneAPI/mkl/2026.1/lib/cmake/mkl" "-DTBB_DIR=C:/Program Files (x86)/Intel/oneAPI/tbb/2023.1/lib/cmake/tbb" "-DHDF5_DIR=C:/Program Files/HDF_Group/HDF5/2.1.1/cmake"` | 0 | Approved MSVC x64 build tree regenerates with normalized dependencies | Configure and generate completed; MKL 2026.1 resolved | +| VERIFY-build | `cmake --build .harness/build --config Debug` | 0 | Full Debug build passes without a new FESA warning | `fesa_solver.lib` and `fesa_unit_tests.exe` built under `/W4 /WX` | +| VERIFY-targeted | `ctest --test-dir .harness/build -C Debug -R CoreDiagnostics --output-on-failure` | 0 | Focused Step 8 suite remains green | 3/3 tests passed | +| VERIFY-discovery | `ctest --test-dir .harness/build -C Debug --show-only=json-v1` | 0 | CTest discovers the existing and new named tests | 5 tests discovered with feature and unit labels | +| VERIFY-full | `ctest --test-dir .harness/build -C Debug --output-on-failure` | 0 | Full accumulated C++ suite has zero failures | 5/5 tests passed | +| VERIFY-harness | `uv run --with pytest python -m pytest -v -rs` | 0 | Harness regression suite remains green | 4/4 tests passed | + +- contract_checks: `SourceEntityId` preserves numeric and raw label forms; + `sortDiagnostics()` applies the fixed file/line/keyword/entity/code tuple with + stable discovery-order ties; `Status` owns sorted diagnostics and optional + failure category; `Result` owns either a value or failed status and rejects + both failed value access and construction from an OK status. Core public + headers include only FESA core or C++ standard-library headers; no + MKL/TBB/HDF5 names are present. +- generated_evidence: `.harness/build/src/fesa/Debug/fesa_solver.lib`, + `.harness/build/tests/Debug/fesa_unit_tests.exe` +- reference_diff: unchanged +- handoff: `SourceLocation`, `SourceEntityId`, `Diagnostic`, deterministic + diagnostic sorting, `FailureCategory`, `Status`, and `Result` are available + to Step 9 and later parser/model/solver tasks. diff --git a/include/fesa/core/diagnostic.hpp b/include/fesa/core/diagnostic.hpp new file mode 100644 index 0000000..df68cc7 --- /dev/null +++ b/include/fesa/core/diagnostic.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include "fesa/core/source_identity.hpp" + +#include +#include + +namespace fesa { + +// Distinguishes recoverable warnings from errors that stop the current operation. +enum class Severity { + warning, + error +}; + +// Carries a structured, backend-independent diagnostic record. +struct Diagnostic { + Severity severity; + std::string code; + SourceLocation location; + std::string keyword; + std::string entityIdentity; + std::string message; +}; + +// Orders diagnostics by their externally visible source tuple while retaining +// discovery order for records with identical keys. +void sortDiagnostics(std::vector& diagnostics); + +} // namespace fesa diff --git a/include/fesa/core/source_identity.hpp b/include/fesa/core/source_identity.hpp new file mode 100644 index 0000000..61732ac --- /dev/null +++ b/include/fesa/core/source_identity.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include +#include + +namespace fesa { + +// Identifies the physical input location that produced a model item or diagnostic. +struct SourceLocation { + std::filesystem::path file; + std::size_t line; +}; + +// Preserves both semantic and raw-text forms of an input entity identity. +struct SourceEntityId { + std::string instanceName; + std::int64_t sourceLabel; + std::string sourceLabelText; +}; + +} // namespace fesa diff --git a/include/fesa/core/status.hpp b/include/fesa/core/status.hpp new file mode 100644 index 0000000..ee8313b --- /dev/null +++ b/include/fesa/core/status.hpp @@ -0,0 +1,94 @@ +#pragma once + +#include "fesa/core/diagnostic.hpp" + +#include +#include +#include +#include + +namespace fesa { + +// Maps a failure to the stable command-line exit-code classes defined by V0. +enum class FailureCategory { + input, + model, + solver, + output +}; + +// Transports success or structured diagnostics without exposing backend errors. +class Status { +public: + static Status ok(); + static Status failure(std::vector diagnostics); + static Status failure( + FailureCategory category, std::vector diagnostics); + + bool isOk() const noexcept; + std::optional failureCategory() const noexcept; + const std::vector& diagnostics() const noexcept; + +private: + Status( + bool isOk, + std::optional category, + std::vector diagnostics); + + bool isOk_; + std::optional category_; + std::vector diagnostics_; +}; + +// Owns exactly one successful value or one failed Status. +template +class Result { +public: + static Result success(T value) { + return Result{SuccessTag{}, std::move(value)}; + } + + static Result failure(Status status) { + if (status.isOk()) { + throw std::invalid_argument{"A failed Result requires a failed Status."}; + } + return Result{FailureTag{}, std::move(status)}; + } + + bool hasValue() const noexcept { + return value_.has_value(); + } + + T& value() { + if (!value_) { + throw std::logic_error{"Result has no value."}; + } + return *value_; + } + + const T& value() const { + if (!value_) { + throw std::logic_error{"Result has no value."}; + } + return *value_; + } + + const Status& status() const noexcept { + return status_; + } + +private: + struct SuccessTag {}; + struct FailureTag {}; + + Result(SuccessTag, T value) + : value_{std::move(value)}, status_{Status::ok()} {} + + Result(FailureTag, Status status) + : value_{std::nullopt}, status_{std::move(status)} {} + + std::optional value_; + Status status_; +}; + +} // namespace fesa diff --git a/src/fesa/CMakeLists.txt b/src/fesa/CMakeLists.txt index 6a78be9..a21a90a 100644 --- a/src/fesa/CMakeLists.txt +++ b/src/fesa/CMakeLists.txt @@ -2,6 +2,8 @@ add_library( fesa_solver STATIC build_info.cpp + core/diagnostic.cpp + core/status.cpp ) target_include_directories( diff --git a/src/fesa/core/diagnostic.cpp b/src/fesa/core/diagnostic.cpp new file mode 100644 index 0000000..06e355d --- /dev/null +++ b/src/fesa/core/diagnostic.cpp @@ -0,0 +1,30 @@ +#include "fesa/core/diagnostic.hpp" + +#include +#include + +namespace fesa { + +void sortDiagnostics(std::vector& diagnostics) { + // stable_sort makes discovery order the final tie-breaker without storing it + // in the externally visible Diagnostic record. + std::stable_sort( + diagnostics.begin(), + diagnostics.end(), + [](const Diagnostic& left, const Diagnostic& right) { + return std::tie( + left.location.file, + left.location.line, + left.keyword, + left.entityIdentity, + left.code) < + std::tie( + right.location.file, + right.location.line, + right.keyword, + right.entityIdentity, + right.code); + }); +} + +} // namespace fesa diff --git a/src/fesa/core/status.cpp b/src/fesa/core/status.cpp new file mode 100644 index 0000000..6563352 --- /dev/null +++ b/src/fesa/core/status.cpp @@ -0,0 +1,42 @@ +#include "fesa/core/status.hpp" + +#include + +namespace fesa { + +Status Status::ok() { + return Status{true, std::nullopt, {}}; +} + +Status Status::failure(std::vector diagnostics) { + sortDiagnostics(diagnostics); + return Status{false, std::nullopt, std::move(diagnostics)}; +} + +Status Status::failure( + FailureCategory category, std::vector diagnostics) { + sortDiagnostics(diagnostics); + return Status{false, category, std::move(diagnostics)}; +} + +bool Status::isOk() const noexcept { + return isOk_; +} + +std::optional Status::failureCategory() const noexcept { + return category_; +} + +const std::vector& Status::diagnostics() const noexcept { + return diagnostics_; +} + +Status::Status( + bool isOk, + std::optional category, + std::vector diagnostics) + : isOk_{isOk}, + category_{category}, + diagnostics_{std::move(diagnostics)} {} + +} // namespace fesa diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 30ee1f3..9337a44 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,6 +3,9 @@ include(GoogleTest) add_executable( fesa_unit_tests unit/build_info_test.cpp + unit/core/diagnostic_test.cpp + unit/core/source_identity_test.cpp + unit/core/status_test.cpp ) target_link_libraries( diff --git a/tests/unit/core/diagnostic_test.cpp b/tests/unit/core/diagnostic_test.cpp new file mode 100644 index 0000000..840e2c2 --- /dev/null +++ b/tests/unit/core/diagnostic_test.cpp @@ -0,0 +1,59 @@ +#include "fesa/core/diagnostic.hpp" + +#include + +#include +#include +#include +#include + +namespace { + +fesa::Diagnostic makeDiagnostic( + std::string file, + std::size_t line, + std::string keyword, + std::string entityIdentity, + std::string code, + std::string message) { + return fesa::Diagnostic{ + fesa::Severity::error, + std::move(code), + {std::filesystem::path{std::move(file)}, line}, + std::move(keyword), + std::move(entityIdentity), + std::move(message)}; +} + +} // namespace + +TEST(CoreDiagnostics, DiagnosticsSortDeterministically) { + std::vector diagnostics{ + makeDiagnostic("b.inp", 1U, "*NODE", "I.1", "a", "file-b"), + makeDiagnostic("a.inp", 3U, "*NODE", "I.1", "z", "code-z"), + makeDiagnostic("a.inp", 3U, "*NODE", "I.1", "a", "first-equal"), + makeDiagnostic("a.inp", 3U, "*NODE", "I.1", "a", "second-equal"), + makeDiagnostic("a.inp", 3U, "*NODE", "I.2", "a", "entity-2"), + makeDiagnostic("a.inp", 3U, "*BOUNDARY", "I.1", "a", "keyword"), + makeDiagnostic("a.inp", 2U, "*NODE", "I.1", "a", "line")}; + + fesa::sortDiagnostics(diagnostics); + + ASSERT_EQ(diagnostics.size(), 7U); + EXPECT_EQ(diagnostics[0].message, "line"); + EXPECT_EQ(diagnostics[1].message, "keyword"); + EXPECT_EQ(diagnostics[2].message, "first-equal"); + EXPECT_EQ(diagnostics[3].message, "second-equal"); + EXPECT_EQ(diagnostics[4].message, "code-z"); + EXPECT_EQ(diagnostics[5].message, "entity-2"); + EXPECT_EQ(diagnostics[6].message, "file-b"); + + const fesa::Diagnostic& exact = diagnostics[2]; + EXPECT_EQ(exact.severity, fesa::Severity::error); + EXPECT_EQ(exact.code, "a"); + EXPECT_EQ(exact.location.file, std::filesystem::path{"a.inp"}); + EXPECT_EQ(exact.location.line, 3U); + EXPECT_EQ(exact.keyword, "*NODE"); + EXPECT_EQ(exact.entityIdentity, "I.1"); + EXPECT_EQ(exact.message, "first-equal"); +} diff --git a/tests/unit/core/source_identity_test.cpp b/tests/unit/core/source_identity_test.cpp new file mode 100644 index 0000000..236a91f --- /dev/null +++ b/tests/unit/core/source_identity_test.cpp @@ -0,0 +1,20 @@ +#include "fesa/core/source_identity.hpp" + +#include + +#include +#include +#include + +TEST(CoreDiagnostics, SourceIdentityPreservesRawIdentity) { + const fesa::SourceLocation location{ + std::filesystem::path{"models/My Beam.inp"}, 27U}; + const fesa::SourceEntityId identity{ + "Beam-Instance_A", std::int64_t{42}, "00042"}; + + EXPECT_EQ(location.file, std::filesystem::path{"models/My Beam.inp"}); + EXPECT_EQ(location.line, 27U); + EXPECT_EQ(identity.instanceName, "Beam-Instance_A"); + EXPECT_EQ(identity.sourceLabel, 42); + EXPECT_EQ(identity.sourceLabelText, "00042"); +} diff --git a/tests/unit/core/status_test.cpp b/tests/unit/core/status_test.cpp new file mode 100644 index 0000000..8798a05 --- /dev/null +++ b/tests/unit/core/status_test.cpp @@ -0,0 +1,66 @@ +#include "fesa/core/status.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace { + +fesa::Diagnostic modelDiagnostic() { + return fesa::Diagnostic{ + fesa::Severity::error, + "invalid-beam-length", + {std::filesystem::path{"beam.inp"}, 12U}, + "*ELEMENT", + "Beam-1.10", + "Beam length must be positive."}; +} + +} // namespace + +TEST(CoreDiagnostics, ResultEnforcesValueErrorExclusivity) { + const fesa::Status ok = fesa::Status::ok(); + EXPECT_TRUE(ok.isOk()); + EXPECT_FALSE(ok.failureCategory().has_value()); + EXPECT_TRUE(ok.diagnostics().empty()); + + const fesa::Status uncategorized = + fesa::Status::failure(std::vector{modelDiagnostic()}); + EXPECT_FALSE(uncategorized.isOk()); + EXPECT_FALSE(uncategorized.failureCategory().has_value()); + ASSERT_EQ(uncategorized.diagnostics().size(), 1U); + EXPECT_EQ(uncategorized.diagnostics()[0].code, "invalid-beam-length"); + + const fesa::Status categorized = fesa::Status::failure( + fesa::FailureCategory::model, + std::vector{modelDiagnostic()}); + EXPECT_FALSE(categorized.isOk()); + ASSERT_TRUE(categorized.failureCategory().has_value()); + EXPECT_EQ(*categorized.failureCategory(), fesa::FailureCategory::model); + + const auto success = fesa::Result::success("solved"); + EXPECT_TRUE(success.hasValue()); + EXPECT_TRUE(success.status().isOk()); + EXPECT_EQ(success.value(), "solved"); + + auto copied = success; + EXPECT_EQ(copied.value(), "solved"); + auto moved = std::move(copied); + EXPECT_EQ(moved.value(), "solved"); + + auto failure = fesa::Result::failure(categorized); + EXPECT_FALSE(failure.hasValue()); + EXPECT_FALSE(failure.status().isOk()); + EXPECT_EQ(failure.status().failureCategory(), fesa::FailureCategory::model); + EXPECT_THROW(failure.value(), std::logic_error); + + const auto& constFailure = failure; + EXPECT_THROW(constFailure.value(), std::logic_error); + EXPECT_THROW( + (void)fesa::Result::failure(fesa::Status::ok()), + std::invalid_argument); +}