diff --git a/.claude/skills/plotjuggler-plugin/SKILL.md b/.claude/skills/plotjuggler-plugin/SKILL.md index c2c47cfb..0fa0a3b0 100644 --- a/.claude/skills/plotjuggler-plugin/SKILL.md +++ b/.claude/skills/plotjuggler-plugin/SKILL.md @@ -166,10 +166,11 @@ DataSource may add `"file_extensions": [".csv"]`. `min_sdk_required` declares the oldest **host** SDK contract your plugin functions on. It is independent of the SDK you build against: `pj_base` / `pj_source` are static libraries compiled into your plugin, so adopting a new -helper (`parseIso8601Utc`, `sliderToWindow`, a codec) **never** raises the +helper (`parseIso8601Utc`, `sliderToWindow`) **never** raises the floor. Only **host-contract** surfaces do — runtime-host tail slots, named -services, wire contracts — and the authority for which release introduced each -is `feature_floors.json`, shipped in the package at +services, wire contracts, and emitting a builtin object type an older host +cannot decode (`MeshMap` needs 1.0.0) — and the authority for which release +introduced each is `feature_floors.json`, shipped in the package at `share/plotjuggler_sdk/feature_floors.json`. When you adopt a surface newer than your floor, pick one: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c1f7209..dc18db92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,10 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is ## [1.0.0] — Unreleased Host contract: extended: builtin object types kMeshMap (21) and kMeshMapUpdate (22) (floor 1.0.0). -Builtin object types are not among the surfaces `feature_floors.json` classifies, so the floor -checker cannot see them: a plugin that emits either type raises its `min_sdk_required` to 1.0.0 -by hand, because an older host cannot decode the object. `Log` gains an optional field and renames +`feature_floors.json` now classifies the `PJ_builtin_object_type_t` enumerators (the types that +shipped before 1.0.0 as baseline), so the floor checker flags a plugin that uses either new type +with a `min_sdk_required` below 1.0.0: the floor is hard, not negotiated, because an older host +cannot decode the object. `Log` gains an optional field and renames one on the same wire, and the widget-protocol changes are documentation only; `pj.playback.v1` already has its floor in `feature_floors.json`. @@ -117,6 +118,26 @@ Removed: - **Breaking (source):** `PJ::sdk::MediaMetadataBuilder` (`pj_base/sdk/media_metadata.hpp`). Use `ObjectTopicMetadataBuilder` (`pj_base/sdk/object_topic_metadata.hpp`). +Fixes: + +- A `PJ_string_view_t` with null data and a non-zero size is now an empty string everywhere the SDK + converts one: `sdk::toStringView`, the service-registry lookup, and the `saveConfig()` readers of + the DataSource, MessageParser and Toolbox handles. They used to read `size` bytes past a one-byte + literal, on views supplied by a plugin. +- `WidgetDataView::chartMarkers()`, `markerTimelineMarks()` and `WidgetEvent::markerTimelineChanged()` + skip a marker entry that is not an object or has a wrong-typed field; they used to throw + `nlohmann::json::type_error`, so one malformed plugin update could take the host down. An integer + field must now hold a JSON integer: a float there (`"start": 3.0`) skips the entry instead of being + truncated. + `WidgetDataView::ChartMarkerView` is now an alias of `PJ::ChartMarker` (same fields). +- `DialogHandle` gains `valid()`; when the plugin's `create()` returns null (its constructor threw), + every method returns empty / false or does nothing instead of passing the null context to the + plugin. +- `WidgetData::toJson()` and `WidgetEventBuilder` replace invalid UTF-8 with U+FFFD; one non-UTF-8 + string (a label read from a binary log) used to make the whole dialog update vanish silently. +- The Conan package now propagates `PJ_ASSERT_THROWS` to consumers when built with + `assert_throws=True`; consumers compiled `PJ_ASSERT` as `assert` against archives built to throw. + ## [0.40.0] Host contract: extended: pj.mcp_endpoint.v1, PJ_mcp_endpoint_host_vtable_t::acquire_endpoint, diff --git a/conanfile.py b/conanfile.py index 486af30f..e863e0e7 100644 --- a/conanfile.py +++ b/conanfile.py @@ -162,6 +162,10 @@ def package_info(self): base.set_property("cmake_target_name", "plotjuggler_sdk::base") base.libs = ["pj_base"] base.includedirs = ["include"] + # PUBLIC on pj_base in CMake, but CMakeDeps ignores the installed targets file: + # consumers must compile PJ_ASSERT the way the archives were built (ODR). + if self.options.assert_throws: + base.defines = ["PJ_ASSERT_THROWS"] # --- plugin_sdk (umbrella INTERFACE: pj_base + pj_dialog_sdk) --- sdk = self.cpp_info.components["plugin_sdk"] diff --git a/pj_base/feature_floors.json b/pj_base/feature_floors.json index d5b327b3..bfb77a34 100644 --- a/pj_base/feature_floors.json +++ b/pj_base/feature_floors.json @@ -53,6 +53,18 @@ }, "acquireMcpEndpoint": "PJ_mcp_endpoint_host_vtable_t::acquire_endpoint", "releaseMcpEndpoint": "PJ_mcp_endpoint_host_vtable_t::release_endpoint" + }, + "1.0.0": { + "kMeshMap": { + "declaration": "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_MESH_MAP", + "negotiated": false, + "aliases": ["MeshMap", "serializeMeshMap"] + }, + "kMeshMapUpdate": { + "declaration": "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_MESH_MAP_UPDATE", + "negotiated": false, + "aliases": ["MeshMapUpdate", "serializeMeshMapUpdate"] + } } }, "baseline": [ @@ -61,6 +73,25 @@ "PJ_DESCRIPTOR_IMPORT_START_FLAG_NONE", "PJ_PARSER_ROUTE_FLAG_OBJECT_V1", "PJ_PARSER_ROUTE_FLAG_SCALAR_V1", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_CAMERA_INFO", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_COMPRESSED_POINTCLOUD", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_DEPTH_IMAGE", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_FRAME_TRANSFORMS", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_GRID_MAP", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_IMAGE", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_IMAGE_ANNOTATIONS", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_LOG", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_MESH3D", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_NONE", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_OCCUPANCY_GRID", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_OCCUPANCY_GRID_UPDATE", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_PLOT_MARKERS", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_POINTCLOUD", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_POSES_IN_FRAME", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_ROBOT_DESCRIPTION", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_SCENE_ENTITIES", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_VIDEO_FRAME", + "PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_VOXEL_GRID", "PJ_data_processors_host_vtable_t::create_data_processor", "PJ_data_processors_host_vtable_t::data_processor_config", "PJ_data_processors_host_vtable_t::list_data_processor_ids", diff --git a/pj_base/include/pj_base/sdk/plugin_data_api.hpp b/pj_base/include/pj_base/sdk/plugin_data_api.hpp index e8dd5135..1e6f77b4 100644 --- a/pj_base/include/pj_base/sdk/plugin_data_api.hpp +++ b/pj_base/include/pj_base/sdk/plugin_data_api.hpp @@ -320,8 +320,12 @@ namespace detail { } // namespace detail +/// Null data reads as empty whatever the size: a malformed {nullptr, size > 0} view is never dereferenced. [[nodiscard]] inline std::string_view toStringView(PJ_string_view_t view) { - return std::string_view(view.data == nullptr ? "" : view.data, view.size); + if (view.data == nullptr) { + return ""; + } + return std::string_view(view.data, view.size); } [[nodiscard]] inline PJ_string_view_t toAbiString(std::string_view view) { diff --git a/pj_base/include/pj_base/sdk/testing/parser_write_recorder.hpp b/pj_base/include/pj_base/sdk/testing/parser_write_recorder.hpp index fa98687b..d8614ec9 100644 --- a/pj_base/include/pj_base/sdk/testing/parser_write_recorder.hpp +++ b/pj_base/include/pj_base/sdk/testing/parser_write_recorder.hpp @@ -173,7 +173,7 @@ class ParserWriteRecorder { void* ctx, PJ_string_view_t name, PJ_primitive_type_t, PJ_field_handle_t* out_field, PJ_error_t*) noexcept { auto* self = static_cast(ctx); uint32_t id = self->next_field_id_++; - self->field_names_.emplace(id, std::string(name.data == nullptr ? "" : name.data, name.size)); + self->field_names_.emplace(id, name.data == nullptr ? std::string() : std::string(name.data, name.size)); *out_field = PJ_field_handle_t{PJ_topic_handle_t{1}, id}; return true; } diff --git a/pj_base/tests/plugin_data_api_test.cpp b/pj_base/tests/plugin_data_api_test.cpp index ce777967..67a192dc 100644 --- a/pj_base/tests/plugin_data_api_test.cpp +++ b/pj_base/tests/plugin_data_api_test.cpp @@ -121,14 +121,10 @@ TEST(PluginDataApiTest, ToStringViewHandlesNullData) { EXPECT_EQ(sdk::toStringView(null_zero), ""); EXPECT_EQ(sdk::toStringView(null_zero).size(), 0U); - // Null data pointer with non-zero size → still uses "" base but with the given size - // The SDK does: std::string_view(view.data == nullptr ? "" : view.data, view.size) - // With nullptr and size=5, it constructs std::string_view("", 5) which has size 5 - // but that reads past the empty string literal — this tests current behavior + // Null data pointer with non-zero size (a malformed, possibly plugin-supplied view) → empty, + // never a view of `size` bytes past a one-byte literal. const PJ_string_view_t null_nonzero{nullptr, 5}; - const auto result = sdk::toStringView(null_nonzero); - // The key thing: it doesn't crash (null guard prevents UB from nullptr dereference) - EXPECT_NE(result.data(), nullptr); + EXPECT_EQ(sdk::toStringView(null_nonzero).size(), 0U); } TEST(PluginDataApiTest, SourceWriteHostViewRejectsMissingAppendArrowStreamTailSlot) { diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/host/dialog_handle.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/host/dialog_handle.hpp index ff4d8e60..16461ea3 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/host/dialog_handle.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/host/dialog_handle.hpp @@ -68,6 +68,15 @@ class DialogHandle { DialogHandle(const DialogHandle&) = delete; DialogHandle& operator=(const DialogHandle&) = delete; + /// False when there is no vtable or no context (the plugin's create() returned + /// null, e.g. its constructor threw, or the handle was moved from). Every method + /// below then returns its "nothing" value (empty string / false / no-op) without + /// calling into the plugin. + /// @since 1.0.0 + [[nodiscard]] bool valid() const noexcept { + return vt_ != nullptr && ctx_ != nullptr; + } + /// Supply immutable host information to this dialog instance. /// /// This method owns the optional-tail-slot gate: runtime engines must call it @@ -84,44 +93,50 @@ class DialogHandle { /// /// @since 0.21.0 [[nodiscard]] SetHostInfoResult setHostInfo(const PJ_dialog_host_info_t& info, PJ_error_t* out_error = nullptr) { - if (vt_ == nullptr || ctx_ == nullptr || !PJ_HAS_TAIL_SLOT(PJ_dialog_vtable_t, vt_, set_host_info)) { + if (!valid() || !PJ_HAS_TAIL_SLOT(PJ_dialog_vtable_t, vt_, set_host_info)) { return SetHostInfoResult::Unsupported; } return vt_->set_host_info(ctx_, &info, out_error) ? SetHostInfoResult::Accepted : SetHostInfoResult::Rejected; } - // --- Queries --- + // --- Queries (empty when !valid()) --- [[nodiscard]] std::string manifest() const { - return safeString(vt_->get_manifest(ctx_)); + return valid() ? safeString(vt_->get_manifest(ctx_)) : std::string(); } [[nodiscard]] std::string ui_content() const { - return safeString(vt_->get_ui_content(ctx_)); + return valid() ? safeString(vt_->get_ui_content(ctx_)) : std::string(); } [[nodiscard]] std::string widget_data() const { - return safeString(vt_->get_widget_data(ctx_)); + return valid() ? safeString(vt_->get_widget_data(ctx_)) : std::string(); } - // --- Events (fallible — errors swallowed here; callers that need detail call vtable directly) --- + // --- Events (fallible — errors swallowed here; callers that need detail call vtable directly; false when + // !valid()) --- [[nodiscard]] bool sendEvent(std::string_view widget_name, std::string_view event_json) { - return vt_->on_widget_event(ctx_, std::string(widget_name).c_str(), std::string(event_json).c_str(), nullptr); + return valid() && + vt_->on_widget_event(ctx_, std::string(widget_name).c_str(), std::string(event_json).c_str(), nullptr); } [[nodiscard]] bool tick() { - return vt_->on_tick(ctx_, nullptr); + return valid() && vt_->on_tick(ctx_, nullptr); } - // --- Dialog result --- + // --- Dialog result (no-ops when !valid()) --- void accept(std::string_view final_state_json) { - vt_->on_accepted(ctx_, std::string(final_state_json).c_str()); + if (valid()) { + vt_->on_accepted(ctx_, std::string(final_state_json).c_str()); + } } void reject() { - vt_->on_rejected(ctx_); + if (valid()) { + vt_->on_rejected(ctx_); + } } - // --- Config persistence --- + // --- Config persistence (empty / false when !valid()) --- [[nodiscard]] std::string save_config() const { PJ_string_view_t sv{}; - if (!vt_->save_config(ctx_, &sv, nullptr)) { + if (!valid() || !vt_->save_config(ctx_, &sv, nullptr)) { return std::string(); } return sv.data == nullptr ? std::string() : std::string(sv.data, sv.size); @@ -129,7 +144,7 @@ class DialogHandle { [[nodiscard]] bool load_config(std::string_view config_json) { PJ_string_view_t sv{config_json.data(), config_json.size()}; - return vt_->load_config(ctx_, sv, nullptr); + return valid() && vt_->load_config(ctx_, sv, nullptr); } // --- Escape hatch --- diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp index 81bdc808..40ef7a3a 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_data_view.hpp @@ -18,7 +18,7 @@ #include "pj_base/expected.hpp" #include "pj_base/number_parse.hpp" -#include "pj_plugins/sdk/widget_data.hpp" // TimelineMark, SceneView +#include "pj_plugins/sdk/widget_data.hpp" // ChartMarker, TimelineMark, SceneView namespace PJ { @@ -706,20 +706,13 @@ class WidgetDataView { return view; } - /// A marker overlaid on the chart (see PJ::ChartMarker / setChartMarkers). + /// A marker overlaid on the chart: the plugin-side PJ::ChartMarker (see setChartMarkers), + /// decoded by the shared chartMarkersFromJson. /// Interpret by `kind`: "event" (point at x0,y0 if has_value, else vline at x0), /// "region" (x-span [x0,x1]), "value_band" (y-band [y0,y1]; line when y0==y1), "label". - struct ChartMarkerView { - std::string kind; - double x0 = 0.0; - double x1 = 0.0; - double y0 = 0.0; - double y1 = 0.0; - bool has_value = false; - std::string color; // optional hex "#rrggbb"; empty means use a default palette - std::string label; - }; + using ChartMarkerView = ChartMarker; + /// Entries that are not objects or carry a wrong-typed field are skipped (never throws). [[nodiscard]] std::optional> chartMarkers(std::string_view name) const { const nlohmann::json* w = widget(name); if (!w) { @@ -729,24 +722,7 @@ class WidgetDataView { if (it == w->end() || !it->is_array()) { return std::nullopt; } - std::vector result; - result.reserve(it->size()); - for (const auto& m : *it) { - if (!m.is_object()) { - continue; - } - ChartMarkerView mv; - mv.kind = m.value("kind", std::string()); - mv.x0 = m.value("x0", 0.0); - mv.x1 = m.value("x1", 0.0); - mv.y0 = m.value("y0", 0.0); - mv.y1 = m.value("y1", 0.0); - mv.has_value = m.value("has_value", false); - mv.color = m.value("color", std::string()); - mv.label = m.value("label", std::string()); - result.push_back(std::move(mv)); - } - return result; + return chartMarkersFromJson(*it); } /// Returns whether interactive zoom is enabled on this chart widget. diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_event_builder.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_event_builder.hpp index 93a18edd..4f691a33 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_event_builder.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/host/widget_event_builder.hpp @@ -18,14 +18,14 @@ struct WidgetEventBuilder { [[nodiscard]] static std::string textChanged(std::string_view text) { nlohmann::json j; j["text"] = text; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QComboBox: index changed (index only) [[nodiscard]] static std::string indexChanged(int index) { nlohmann::json j; j["current_index"] = index; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QComboBox: index changed (index + current text) @@ -33,56 +33,56 @@ struct WidgetEventBuilder { nlohmann::json j; j["current_index"] = index; j["current_text"] = current_text; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QCheckBox, QRadioButton: toggled [[nodiscard]] static std::string toggled(bool checked) { nlohmann::json j; j["checked"] = checked; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QSpinBox: value changed (int) [[nodiscard]] static std::string valueChanged(int value) { nlohmann::json j; j["value"] = value; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QDoubleSpinBox: value changed (double) [[nodiscard]] static std::string valueChanged(double value) { nlohmann::json j; j["value"] = value; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QListWidget: selection changed [[nodiscard]] static std::string selectionChanged(const std::vector& selected) { nlohmann::json j; j["selected_items"] = selected; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QPushButton: clicked [[nodiscard]] static std::string clicked() { nlohmann::json j; j["clicked"] = true; - return j.dump(); + return detail::dumpWidgetJson(j); } /// File picker: file selected [[nodiscard]] static std::string fileSelected(std::string_view path) { nlohmann::json j; j["file_selected"] = path; - return j.dump(); + return detail::dumpWidgetJson(j); } /// Folder picker: folder selected [[nodiscard]] static std::string folderSelected(std::string_view path) { nlohmann::json j; j["folder_selected"] = path; - return j.dump(); + return detail::dumpWidgetJson(j); } /// Structured file-picker result. A fully valid Selected result co-emits its @@ -102,7 +102,7 @@ struct WidgetEventBuilder { if (auto legacy = detail::filePickerLegacySelection(result)) { j[std::string(legacy->key)] = legacy->path; } - return j.dump(); + return detail::dumpWidgetJson(j); } /// Embedded scene view: a request the host could not honour. Deliver it to the @@ -112,14 +112,14 @@ struct WidgetEventBuilder { nlohmann::json j; j["scene_view_error"] = { {"kind", error.kind}, {"topic", error.topic}, {"dataset", error.dataset}, {"reason", error.reason}}; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QTabWidget: tab changed [[nodiscard]] static std::string tabChanged(int index) { nlohmann::json j; j["tab_index"] = index; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QStackedWidget: current page changed. Always emits both the transient @@ -129,7 +129,7 @@ struct WidgetEventBuilder { nlohmann::json j; j["stacked_index"] = index; j["stacked_page"] = page_object_name; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QTreeWidget: selection changed. `ids` is the complete logical selected-ID @@ -138,7 +138,7 @@ struct WidgetEventBuilder { [[nodiscard]] static std::string treeSelectionChanged(const std::vector& ids) { nlohmann::json j; j["tree_selection_changed"] = ids; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QTreeWidget: an item was activated by keyboard or double-click. @@ -146,7 +146,7 @@ struct WidgetEventBuilder { [[nodiscard]] static std::string treeItemActivated(std::string_view id, int column) { nlohmann::json j; j["tree_item_activated"] = {{"id", id}, {"column", column}}; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QTreeWidget: an item's expanded state changed. @@ -154,7 +154,7 @@ struct WidgetEventBuilder { [[nodiscard]] static std::string treeExpansionChanged(std::string_view id, bool expanded) { nlohmann::json j; j["tree_expansion_changed"] = {{"id", id}, {"expanded", expanded}}; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QTreeWidget: an item's column-0 check state changed. @@ -162,21 +162,21 @@ struct WidgetEventBuilder { [[nodiscard]] static std::string treeCheckStateChanged(std::string_view id, TreeCheckState state) { nlohmann::json j; j["tree_check_state_changed"] = {{"id", id}, {"state", treeCheckStateWireValue(state)}}; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QListWidget: item double-clicked [[nodiscard]] static std::string itemDoubleClicked(int index) { nlohmann::json j; j["item_double_clicked_index"] = index; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QListWidget (deletable): a row's trailing delete (trash) button was clicked. [[nodiscard]] static std::string itemDeleteRequested(int index) { nlohmann::json j; j["item_delete_index"] = index; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QListWidget: a row's context menu (declared via the widget's @@ -185,7 +185,7 @@ struct WidgetEventBuilder { [[nodiscard]] static std::string itemContextAction(int index, std::string_view action_id) { nlohmann::json j; j["item_context_action"] = {{"index", index}, {"action", action_id}}; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QTableWidget: a horizontal-header section was clicked (column index). @@ -194,14 +194,14 @@ struct WidgetEventBuilder { [[nodiscard]] static std::string headerClicked(int section) { nlohmann::json j; j["header_section"] = section; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QTableWidget: a radio button in the radio column was selected (row index). [[nodiscard]] static std::string tableRadioSelected(int row) { nlohmann::json j; j["table_radio_row"] = row; - return j.dump(); + return detail::dumpWidgetJson(j); } /// Code editor: code changed. `cursor` is the caret offset (bytes) in the new @@ -213,14 +213,14 @@ struct WidgetEventBuilder { if (cursor >= 0) { j["code_cursor"] = cursor; } - return j.dump(); + return detail::dumpWidgetJson(j); } /// Drag-and-drop: items dropped on a widget (curves, files, or any draggable payload). [[nodiscard]] static std::string itemsDropped(const std::vector& labels) { nlohmann::json j; j["items_dropped"] = labels; - return j.dump(); + return detail::dumpWidgetJson(j); } /// Same, with the catalog data source of each label (parallel to `labels`, 0 = unknown). The labels stay bare names, @@ -230,7 +230,7 @@ struct WidgetEventBuilder { nlohmann::json j; j["items_dropped"] = labels; j["items_dropped_data_sources"] = data_source_ids; - return j.dump(); + return detail::dumpWidgetJson(j); } /// DateRangePicker: date/time range filter changed. from/to are ISO-8601 @@ -239,7 +239,7 @@ struct WidgetEventBuilder { nlohmann::json j; j["date_from_iso"] = from_iso; j["date_to_iso"] = to_iso; - return j.dump(); + return detail::dumpWidgetJson(j); } /// QDateTimeEdit: displayed datetime edited (ISO-8601, e.g. "2026-05-21T13:45:00"). @@ -247,7 +247,7 @@ struct WidgetEventBuilder { [[nodiscard]] static std::string dateTimeChanged(std::string_view iso8601) { nlohmann::json j; j["datetime_iso"] = iso8601; - return j.dump(); + return detail::dumpWidgetJson(j); } /// RangeSlider: lower/upper handle position changed (slider units). @@ -255,7 +255,7 @@ struct WidgetEventBuilder { nlohmann::json j; j["range_lower"] = lower; j["range_upper"] = upper; - return j.dump(); + return detail::dumpWidgetJson(j); } /// MarkerTimeline: the mark set changed (user drag / resize / delete). Carries @@ -263,7 +263,7 @@ struct WidgetEventBuilder { [[nodiscard]] static std::string markerTimelineChanged(const std::vector& marks) { nlohmann::json j; j["marker_timeline_marks"] = timelineMarksToJson(marks); - return j.dump(); + return detail::dumpWidgetJson(j); } /// ChartPreviewWidget: visible range changed via zoom or pan. @@ -273,7 +273,7 @@ struct WidgetEventBuilder { j["chart_x_max"] = x_max; j["chart_y_min"] = y_min; j["chart_y_max"] = y_max; - return j.dump(); + return detail::dumpWidgetJson(j); } }; diff --git a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp index fe526874..294b93ed 100644 --- a/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp +++ b/pj_plugins/dialog_protocol/include/pj_plugins/sdk/widget_data.hpp @@ -3,6 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 #include +#include #include #include #include @@ -127,6 +128,53 @@ struct ChartMarker { std::string label; }; +namespace detail { + +/// Reads the optional `key` of a JSON object into `out` without throwing. Absent: `out` keeps +/// its default, true. Present with the wrong type (or an integer outside `int`): false, so the +/// marker decoders below skip that entry instead of letting nlohmann's value() throw. +template +[[nodiscard]] bool readOptionalWidgetField(const nlohmann::json& obj, const char* key, T& out) { + const auto it = obj.find(key); + if (it == obj.end()) { + return true; + } + if constexpr (std::is_same_v) { + if (!it->is_string()) { + return false; + } + } else if constexpr (std::is_same_v) { + if (!it->is_boolean()) { + return false; + } + } else if constexpr (std::is_same_v) { + if (!it->is_number()) { + return false; + } + } else { + static_assert(std::is_same_v); + if (it->is_number_unsigned()) { + if (it->template get() > static_cast(std::numeric_limits::max())) { + return false; + } + } else if ( + !it->is_number_integer() || it->template get() < std::numeric_limits::min() || + it->template get() > std::numeric_limits::max()) { + return false; + } + } + out = it->template get(); + return true; +} + +/// Serializes widget data / widget events. Invalid UTF-8 (a label read from a binary log) +/// becomes U+FFFD instead of throwing, which would discard the whole document. +[[nodiscard]] inline std::string dumpWidgetJson(const nlohmann::json& json) { + return json.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace); +} + +} // namespace detail + /// The single source of truth for the ChartMarker ⇆ JSON wire shape, shared by the /// data setter and the data-view (so a field change cannot drift between them). [[nodiscard]] inline nlohmann::json chartMarkersToJson(const std::vector& marks) { @@ -145,6 +193,7 @@ struct ChartMarker { return arr; } +/// Never throws: an entry that is not an object, or has a field of the wrong type, is skipped. [[nodiscard]] inline std::vector chartMarkersFromJson(const nlohmann::json& arr) { std::vector marks; if (!arr.is_array()) { @@ -153,15 +202,14 @@ struct ChartMarker { marks.reserve(arr.size()); for (const auto& jm : arr) { ChartMarker m; - m.kind = jm.value("kind", std::string()); - m.x0 = jm.value("x0", 0.0); - m.x1 = jm.value("x1", 0.0); - m.y0 = jm.value("y0", 0.0); - m.y1 = jm.value("y1", 0.0); - m.has_value = jm.value("has_value", false); - m.color = jm.value("color", std::string()); - m.label = jm.value("label", std::string()); - marks.push_back(std::move(m)); + if (jm.is_object() && detail::readOptionalWidgetField(jm, "kind", m.kind) && + detail::readOptionalWidgetField(jm, "x0", m.x0) && detail::readOptionalWidgetField(jm, "x1", m.x1) && + detail::readOptionalWidgetField(jm, "y0", m.y0) && detail::readOptionalWidgetField(jm, "y1", m.y1) && + detail::readOptionalWidgetField(jm, "has_value", m.has_value) && + detail::readOptionalWidgetField(jm, "color", m.color) && + detail::readOptionalWidgetField(jm, "label", m.label)) { + marks.push_back(std::move(m)); + } } return marks; } @@ -200,6 +248,8 @@ struct TimelineMark { return arr; } +/// Never throws: an entry that is not an object, or has a field of the wrong type (or an +/// integer outside `int`), is skipped. [[nodiscard]] inline std::vector timelineMarksFromJson(const nlohmann::json& arr) { std::vector marks; if (!arr.is_array()) { @@ -208,11 +258,11 @@ struct TimelineMark { marks.reserve(arr.size()); for (const auto& jm : arr) { TimelineMark m; - m.id = jm.value("id", 0); - m.region = jm.value("region", true); - m.start = jm.value("start", 0); - m.end = jm.value("end", 0); - marks.push_back(m); + if (jm.is_object() && detail::readOptionalWidgetField(jm, "id", m.id) && + detail::readOptionalWidgetField(jm, "region", m.region) && + detail::readOptionalWidgetField(jm, "start", m.start) && detail::readOptionalWidgetField(jm, "end", m.end)) { + marks.push_back(m); + } } return marks; } @@ -1095,7 +1145,7 @@ class WidgetData { /// Serialize to JSON string std::string toJson() const { - return data_.dump(); + return detail::dumpWidgetJson(data_); } /// Reset for reuse diff --git a/pj_plugins/dialog_protocol/tests/dialog_handle_test.cpp b/pj_plugins/dialog_protocol/tests/dialog_handle_test.cpp index b9a24b48..6c5a7b55 100644 --- a/pj_plugins/dialog_protocol/tests/dialog_handle_test.cpp +++ b/pj_plugins/dialog_protocol/tests/dialog_handle_test.cpp @@ -71,6 +71,28 @@ TEST_F(DialogHandleTest, ConstructAndDestroy) { EXPECT_NE(h.context(), nullptr); } +TEST_F(DialogHandleTest, FailedCreateIsInvalidAndNeverCallsThePlugin) { + // The plugin macro's create() returns nullptr when the plugin constructor throws. + // Every other slot is null: calling any of them with the null context would crash. + PJ_dialog_vtable_t vtable{}; + vtable.protocol_version = PJ_DIALOG_PROTOCOL_VERSION; + vtable.struct_size = sizeof(PJ_dialog_vtable_t); + vtable.create = []() noexcept -> void* { return nullptr; }; + PJ::DialogHandle h(&vtable); + + EXPECT_FALSE(h.valid()); + EXPECT_EQ(h.manifest(), ""); + EXPECT_EQ(h.ui_content(), ""); + EXPECT_EQ(h.widget_data(), ""); + EXPECT_FALSE(h.sendEvent("w", "{}")); + EXPECT_FALSE(h.tick()); + h.accept("{}"); + h.reject(); + EXPECT_EQ(h.save_config(), ""); + EXPECT_FALSE(h.load_config("{}")); + EXPECT_EQ(h.setHostInfo(testHostInfo()), PJ::SetHostInfoResult::Unsupported); +} + TEST_F(DialogHandleTest, NullVtable) { // Null vtable should be safe — no crash PJ::DialogHandle h(nullptr); diff --git a/pj_plugins/dialog_protocol/tests/widget_data_test.cpp b/pj_plugins/dialog_protocol/tests/widget_data_test.cpp index a7c92819..702dec57 100644 --- a/pj_plugins/dialog_protocol/tests/widget_data_test.cpp +++ b/pj_plugins/dialog_protocol/tests/widget_data_test.cpp @@ -644,3 +644,11 @@ TEST(WidgetDataTest, SetTreeVisibleIdsAfterClearReEmitsFilterInSameBuilder) { wd.clearTreeVisibleIds("topicTree").setTreeVisibleIds("topicTree", {"imu", "gps"}); EXPECT_EQ(parse(wd)["topicTree"]["tree_visible_ids"], nlohmann::json({"imu", "gps"})); } + +TEST(WidgetDataTest, InvalidUtf8IsReplacedInsteadOfDroppingTheUpdate) { + WidgetData wd; + wd.setText("label", "can\xff").setText("other", "fine"); // e.g. a channel name read from a binary log + const auto j = parse(wd); + EXPECT_EQ(j["label"]["text"], "can\xEF\xBF\xBD"); // U+FFFD + EXPECT_EQ(j["other"]["text"], "fine"); +} diff --git a/pj_plugins/dialog_protocol/tests/widget_data_view_test.cpp b/pj_plugins/dialog_protocol/tests/widget_data_view_test.cpp index 5dbaebed..7dd8eddd 100644 --- a/pj_plugins/dialog_protocol/tests/widget_data_view_test.cpp +++ b/pj_plugins/dialog_protocol/tests/widget_data_view_test.cpp @@ -6,10 +6,12 @@ #include #include #include +#include #include #include #include #include +#include TEST(WidgetDataViewTest, TimeSpansPreserveSignedNanosecondLimits) { constexpr auto minimum = std::numeric_limits::min(); @@ -1012,3 +1014,25 @@ TEST(WidgetDataViewSceneViewTest, TrailingJunkAfterTheDocumentIsNotAWidgetUpdate const PJ::WidgetDataView view(R"({"preview":{"scene_view":{"kind":"3d","topics":[]}}} trailing)"); EXPECT_FALSE(view.sceneView("preview").has_value()); } + +TEST(WidgetDataViewTest, MalformedMarkerEntriesAreSkippedWithoutThrowing) { + const PJ::WidgetDataView view(R"({ + "chart": {"chart_markers": [5, {"kind": 1}, {"kind": "event", "x0": "1"}, {"kind": "region", "x0": 1, "x1": 2.5}]}, + "timeline": {"marker_timeline_marks": [5, {"id": "a"}, {"id": 1, "start": 4294967296}, + {"id": 2, "region": false, "start": 3, "end": 4}]} + })"); + std::optional> markers; + ASSERT_NO_THROW(markers = view.chartMarkers("chart")); + ASSERT_TRUE(markers.has_value()); + ASSERT_EQ(markers->size(), 1U); + EXPECT_EQ(markers->front().kind, "region"); + EXPECT_EQ(markers->front().x1, 2.5); + + std::optional> marks; + ASSERT_NO_THROW(marks = view.markerTimelineMarks("timeline")); + ASSERT_TRUE(marks.has_value()); + ASSERT_EQ(marks->size(), 1U); + EXPECT_EQ(marks->front().id, 2); + EXPECT_FALSE(marks->front().region); + EXPECT_EQ(marks->front().end, 4); +} diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index 7cdff2c5..8fc1aece 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -6,8 +6,8 @@ Seven rules the loader and every plugin author rely on. Breaking any of these is an ABI break and requires a future `PJ_ABI_VERSION` bump. One classification obligation rides alongside these rules: **a new host -surface — a host-vtable tail slot, a versioned service/extension id, or a -wire-contract flag — must be classified in `pj_base/feature_floors.json` in +surface — a host-vtable tail slot, a versioned service/extension id, a +wire-contract flag, or a builtin object type — must be classified in `pj_base/feature_floors.json` in the same PR.** CI enforces it (`tools/feature_floors/check_feature_floors.py` diffs the extracted host-surface inventory against the checked-in snapshot), and the installed copy of the table is what downstream plugin repositories diff --git a/pj_plugins/include/pj_plugins/host/data_source_handle.hpp b/pj_plugins/include/pj_plugins/host/data_source_handle.hpp index c7a514df..dba5c6dc 100644 --- a/pj_plugins/include/pj_plugins/host/data_source_handle.hpp +++ b/pj_plugins/include/pj_plugins/host/data_source_handle.hpp @@ -111,7 +111,7 @@ class DataSourceHandle { if (!vt_->save_config(ctx_, &sv, &err)) { return unexpected(errorToString(err)); } - out_json.assign(sv.data == nullptr ? "" : sv.data, sv.size); + out_json.assign(sv.data == nullptr ? std::string_view() : std::string_view(sv.data, sv.size)); return okStatus(); } diff --git a/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp b/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp index 69dec663..bf9851ca 100644 --- a/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp +++ b/pj_plugins/include/pj_plugins/host/message_parser_handle.hpp @@ -112,7 +112,7 @@ class MessageParserHandle { if (!vt_->save_config(ctx_, &sv, &err)) { return unexpected(errorToString(err)); } - out_json.assign(sv.data == nullptr ? "" : sv.data, sv.size); + out_json.assign(sv.data == nullptr ? std::string_view() : std::string_view(sv.data, sv.size)); return okStatus(); } diff --git a/pj_plugins/include/pj_plugins/host/service_registry_builder.hpp b/pj_plugins/include/pj_plugins/host/service_registry_builder.hpp index b5d5e868..d728d6be 100644 --- a/pj_plugins/include/pj_plugins/host/service_registry_builder.hpp +++ b/pj_plugins/include/pj_plugins/host/service_registry_builder.hpp @@ -112,7 +112,8 @@ class ServiceRegistryBuilder { } return false; } - std::string key(name.data == nullptr ? "" : name.data, name.size); + // A null view is empty whatever its size: never read `size` bytes from a literal. + const std::string key = name.data == nullptr ? std::string() : std::string(name.data, name.size); auto it = self->entries_.find(key); if (it == self->entries_.end()) { if (out_error != nullptr) { diff --git a/pj_plugins/include/pj_plugins/host/toolbox_handle.hpp b/pj_plugins/include/pj_plugins/host/toolbox_handle.hpp index 1f16b128..a3850a4b 100644 --- a/pj_plugins/include/pj_plugins/host/toolbox_handle.hpp +++ b/pj_plugins/include/pj_plugins/host/toolbox_handle.hpp @@ -81,7 +81,7 @@ class ToolboxHandle { if (!vt_->save_config(ctx_, &sv, &err)) { return unexpected(errorToString(err)); } - out_json.assign(sv.data == nullptr ? "" : sv.data, sv.size); + out_json.assign(sv.data == nullptr ? std::string_view() : std::string_view(sv.data, sv.size)); return okStatus(); } diff --git a/pj_plugins/tests/service_registry_builder_test.cpp b/pj_plugins/tests/service_registry_builder_test.cpp index b55d568c..888bd423 100644 --- a/pj_plugins/tests/service_registry_builder_test.cpp +++ b/pj_plugins/tests/service_registry_builder_test.cpp @@ -88,4 +88,18 @@ TEST(ServiceRegistryBuilderTest, TemplatedOverloadAssertsOnDuplicate) { EXPECT_EQ(builder.size(), 1U); } +// A plugin-supplied name view with a null pointer and a non-zero size is an +// empty name, not `size` bytes read from a string literal (an out-of-bounds +// read that ASAN reports). +TEST(ServiceRegistryBuilderTest, NullNameViewWithNonZeroSizeIsAnUnknownService) { + PJ::ServiceRegistryBuilder builder; + builder.registerService(makeWriteHost(&ctx_a)); + const PJ_service_registry_t registry = builder.view(); + + PJ_service_t out{}; + PJ_error_t error{}; + EXPECT_FALSE(registry.vtable->get_service(registry.ctx, PJ_string_view_t{nullptr, 64}, 1, &out, &error)); + EXPECT_EQ(out.ctx, nullptr); +} + } // namespace diff --git a/tools/feature_floors/extract_host_surfaces.py b/tools/feature_floors/extract_host_surfaces.py index d0d0e96a..806fa325 100644 --- a/tools/feature_floors/extract_host_surfaces.py +++ b/tools/feature_floors/extract_host_surfaces.py @@ -4,7 +4,7 @@ """Inventory the SDK's HOST-CONTRACT surfaces from the protocol headers. The inventory is what feature_floors.json must classify: every surface a -plugin can require from the HOST at runtime. Three kinds are extracted: +plugin can require from the HOST at runtime. Five kinds are extracted: 1. Callable members of host-side vtables (top-level function-pointer members, including typedef'd callable types) — emitted as @@ -20,6 +20,10 @@ 4. Members of the dialog host capability enum, emitted as "PJ_dialog_host_capability_t::": a bit the host announces and a dialog plugin gates on. + 5. Members of the builtin object type enum, emitted as + "PJ_builtin_object_type_t::": a wire contract the host must + decode when a plugin emits that type. Every enumerator is emitted (NONE + included); reserved values are comments, not enumerators. Plugin-side vtables (PJ_data_source_vtable_t, ...) are deliberately absent: the HOST calls those, so an old host not calling a new plugin slot degrades @@ -95,6 +99,8 @@ DIALOG_CAPABILITY_ENUM = "PJ_dialog_host_capability_t" # Pads the enum to a stable width; not a capability. DIALOG_CAPABILITY_PADDING = "PJ_DIALOG_HOST_CAPABILITY_FORCE_INT32" +BUILTIN_OBJECT_ABI_HEADER = "pj_base/include/pj_base/builtin_object_abi.h" +BUILTIN_OBJECT_TYPE_ENUM = "PJ_builtin_object_type_t" SERVICE_ID_GLOBS = [ ("pj_base/include/pj_base", "**/*.h*"), ("pj_plugins/include", "**/*.h*"), @@ -174,8 +180,18 @@ def _dialog_capabilities(repo: Path) -> set[str]: } +def _builtin_object_types(repo: Path) -> set[str]: + header = repo / BUILTIN_OBJECT_ABI_HEADER + text = strip_comments(header.read_text(encoding="utf-8")) + match = re.search(r"typedef\s+enum\s+" + BUILTIN_OBJECT_TYPE_ENUM + r"\s*\{(.*?)\}", text, flags=re.S) + if match is None: + raise SystemExit(f"{BUILTIN_OBJECT_TYPE_ENUM} not found in {header}") + members = re.findall(r"^\s*(PJ_BUILTIN_OBJECT_TYPE_\w+)\s*=", match.group(1), flags=re.M) + return {f"{BUILTIN_OBJECT_TYPE_ENUM}::{member}" for member in members} + + def extract(repo: Path) -> list[str]: - surfaces: set[str] = _dialog_capabilities(repo) + surfaces: set[str] = _dialog_capabilities(repo) | _builtin_object_types(repo) header_texts: list[tuple[Path, str]] = [] for rel, glob in PROTOCOL_HEADER_GLOBS: for header in sorted((repo / rel).glob(glob)): diff --git a/tools/feature_floors/host_surfaces.snapshot b/tools/feature_floors/host_surfaces.snapshot index 75668fc3..f58204ce 100644 --- a/tools/feature_floors/host_surfaces.snapshot +++ b/tools/feature_floors/host_surfaces.snapshot @@ -9,6 +9,27 @@ PJ_INGEST_COMPLETION_FLAG_ATTESTS_EMPTY_TOPICS PJ_INGEST_COMPLETION_FLAG_NONE PJ_PARSER_ROUTE_FLAG_OBJECT_V1 PJ_PARSER_ROUTE_FLAG_SCALAR_V1 +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_CAMERA_INFO +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_COMPRESSED_POINTCLOUD +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_DEPTH_IMAGE +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_FRAME_TRANSFORMS +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_GRID_MAP +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_IMAGE +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_IMAGE_ANNOTATIONS +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_LOG +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_MESH3D +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_MESH_MAP +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_MESH_MAP_UPDATE +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_NONE +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_OCCUPANCY_GRID +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_OCCUPANCY_GRID_UPDATE +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_PLOT_MARKERS +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_POINTCLOUD +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_POSES_IN_FRAME +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_ROBOT_DESCRIPTION +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_SCENE_ENTITIES +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_VIDEO_FRAME +PJ_builtin_object_type_t::PJ_BUILTIN_OBJECT_TYPE_VOXEL_GRID PJ_data_processors_host_vtable_t::check_data_processor_script PJ_data_processors_host_vtable_t::create_data_processor PJ_data_processors_host_vtable_t::create_data_processor_v2 diff --git a/tools/feature_floors/test_declared_versions.py b/tools/feature_floors/test_declared_versions.py index 46edc6af..12dae581 100644 --- a/tools/feature_floors/test_declared_versions.py +++ b/tools/feature_floors/test_declared_versions.py @@ -62,5 +62,37 @@ def test_every_spelling_is_tracked(self): self.assertEqual(self.run_cli(source), 1, msg=source) +class MeshMapFloorTest(unittest.TestCase): + """Emitting a 1.0.0 builtin object type is a hard 1.0.0 floor (an older host cannot decode it).""" + + def run_cli(self, source: str, floor: str) -> int: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + manifest = root / "manifest.json" + manifest.write_text(json.dumps({"min_sdk_required": floor})) + (root / "src").mkdir() + (root / "src/plugin.cpp").write_text(source) + argv = ["--table", str(TABLE), "--manifest", str(manifest), "--sources", str(root / "src")] + return ffc.main(argv) + + def test_every_spelling_needs_floor_1_0_0(self): + for source in ( + "auto t = PJ::BuiltinObjectType::kMeshMap;", + "auto t = PJ_BUILTIN_OBJECT_TYPE_MESH_MAP_UPDATE;", + "PJ::sdk::MeshMap mesh;", + "auto bytes = PJ::sdk::serializeMeshMapUpdate(update);", + ): + self.assertEqual(self.run_cli(source, "0.40.0"), 1, msg=source) + self.assertEqual(self.run_cli(source, "1.0.0"), 0, msg=source) + + def test_matching_respects_identifier_boundaries(self): + table = ffc.load_surface_table(TABLE) + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "plugin.cpp" + source.write_text("PJ::sdk::MeshMapUpdate update;\n") + matched = {identifier for identifier, _ in ffc.scan_source_file(source, table)} + self.assertEqual(matched, {"kMeshMapUpdate"}) + + if __name__ == "__main__": unittest.main()