From 8ff14dc4a143e3b69f27b7f64a30f3a84bcc28fb Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 6 Sep 2026 22:19:36 +0200 Subject: [PATCH 1/5] feat: feature-floors table, inventory CI guard, built_with_sdk stamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pj_base/feature_floors.json is the authoritative map from host-contract surface to the SDK release that introduced it — the authority a plugin floor checker validates min_sdk_required against. Host surfaces only: client-side static-library helpers compile into the plugin and never constrain the host floor. Six post-baseline surfaces (completion trio of flags + complete_ingest/discard_parser_ingest at 0.30.0, set_dataset_metadata at 0.32.0) plus a 106-entry 0.28.0 baseline, every attribution verified against release tags. tools/feature_floors/ keeps it honest: extract_host_surfaces.py inventories host vtable members, versioned service ids and flag macros into a checked-in snapshot, and check_feature_floors.py (ctest feature_floors_check) fails when a new surface is unclassified, when classifications go out of bounds, or when a host-contract extension lacks its changelog line. The table installs to share/plotjuggler_sdk/feature_floors.json (test_sdk_install.sh step 7). Every .pjmanifest.json now carries built_with_sdk — informational provenance for host diagnostics, never an admission criterion — decoded into PluginDescriptor as an optional field. Release policy docs now state the rule the table encodes: the SDK version is build identity; feature_floors.json carries compatibility semantics; each release's changelog declares 'Host contract: unchanged/extended'. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTJvEsHX26rG4cHmqppvQg --- CHANGELOG.md | 17 ++ CLAUDE.md | 14 ++ CMakeLists.txt | 11 ++ cmake/PjPlugin.cmake | 9 ++ pj_base/feature_floors.json | 145 ++++++++++++++++++ pj_plugins/docs/ARCHITECTURE.md | 8 + .../pj_plugins/host/plugin_catalog.hpp | 5 + pj_plugins/src/plugin_catalog.cpp | 5 + pj_plugins/tests/plugin_catalog_test.cpp | 15 ++ test_sdk_install.sh | 14 ++ .../extract_host_surfaces.cpython-314.pyc | Bin 0 -> 6771 bytes tools/feature_floors/check_feature_floors.py | 106 +++++++++++++ tools/feature_floors/extract_host_surfaces.py | 106 +++++++++++++ tools/feature_floors/host_surfaces.snapshot | 112 ++++++++++++++ 14 files changed, 567 insertions(+) create mode 100644 pj_base/feature_floors.json create mode 100644 tools/feature_floors/__pycache__/extract_host_surfaces.cpython-314.pyc create mode 100644 tools/feature_floors/check_feature_floors.py create mode 100644 tools/feature_floors/extract_host_surfaces.py create mode 100644 tools/feature_floors/host_surfaces.snapshot diff --git a/CHANGELOG.md b/CHANGELOG.md index ccd3dd79..23c35d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is in [`CLAUDE.md`](./CLAUDE.md) → "Release Versioning". +## [0.33.0] — unreleased + +Host contract: unchanged (no floor impact) — this release adds the floor +table itself and the `built_with_sdk` manifest stamp, no new host surfaces. + +- Ship `share/plotjuggler_sdk/feature_floors.json`: the host-contract surface + table (surface → introducing release, runtime-negotiated or not) that a + plugin floor checker validates `min_sdk_required` against. A new host + surface (tail slot, versioned service id, wire flag) must be classified in + the table in the same change — `tools/feature_floors/check_feature_floors.py` + runs in CI and fails on any unclassified surface. +- Stamp `built_with_sdk` (the compile-SDK version) into every plugin's + `.pjmanifest.json` sidecar and expose it as an optional + `PluginDescriptor::built_with_sdk`. Informational provenance for host + diagnostics only — never an admission criterion; older manifests without + the field decode unchanged. + ## [0.32.0] - Add the `set_dataset_metadata` tail slot to the data-source runtime host diff --git a/CLAUDE.md b/CLAUDE.md index c02efb2b..378a206d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,20 @@ Propose a release only according to plugin impact: Tail-append optional slots and gate them by `struct_size`; keep minimum vtable sizes frozen. `abidiff` must show additions only for a MINOR. + +The SDK version is **build identity only** — it names what a plugin compiled +against, never what host it runs on. A release that only adds client-side +helpers (static-library code compiled into the plugin) does NOT change runtime +host compatibility and never mints a `pj_base/feature_floors.json` entry; only +host-contract extensions (host-vtable tail slots, versioned service ids, wire +contracts) do, and those MUST be classified there in the same PR (CI-enforced +by `tools/feature_floors/check_feature_floors.py`). The version stays one +monotonic identity; `feature_floors.json` carries the per-release compatibility +semantics; `PJ_ABI_VERSION` remains the orthogonal hard-break counter. +Changelog convention: every release section carries one `Host contract:` line — +either `unchanged (no floor impact)` or `extended: (floor X.Y.Z)`; +the floor checker requires the `extended` form whenever a surface newer than +the last released version exists. `pj_base/abi/baseline.abi` is refreshed only for an intentional MAJOR break. See [ABI evolution rules](pj_plugins/docs/ARCHITECTURE.md#0a-abi-stability-and-evolution-rules-v5). diff --git a/CMakeLists.txt b/CMakeLists.txt index f9d77a11..0f14cb08 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -113,6 +113,13 @@ find_package(FastFloat REQUIRED) if(PJ_BUILD_TESTS) enable_testing() + # Host-surface classification gate: a new tail slot / service id / wire flag + # must be classified in pj_base/feature_floors.json in the same change. + find_package(Python3 QUIET COMPONENTS Interpreter) + if(Python3_Interpreter_FOUND) + add_test(NAME feature_floors_check + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/feature_floors/check_feature_floors.py) + endif() endif() add_subdirectory(pj_base) @@ -145,6 +152,10 @@ if(PJ_INSTALL_SDK) DESTINATION share/plotjuggler_sdk/docs ) + # Host-surface floor table: the machine-readable authority a plugin floor + # checker validates min_sdk_required against (see tools/feature_floors/). + install(FILES pj_base/feature_floors.json DESTINATION share/plotjuggler_sdk) + # Fixture sources come from the authoritative PJ_SDK_TEST_FIXTURES list # (cmake/PjSdkTestFixtures.cmake, included above). foreach(_fixture IN LISTS PJ_SDK_TEST_FIXTURES) diff --git a/cmake/PjPlugin.cmake b/cmake/PjPlugin.cmake index 927ebae5..af3d1089 100644 --- a/cmake/PjPlugin.cmake +++ b/cmake/PjPlugin.cmake @@ -342,6 +342,15 @@ function(pj_configure_plugin TARGET) set(_sidecar_json "${_src_json}") string(JSON _sidecar_json SET "${_sidecar_json}" "abi_major" "${ARG_ABI_MAJOR}") string(JSON _sidecar_json SET "${_sidecar_json}" "family" "\"${_primary_family}\"") + # Compile-SDK provenance for host diagnostics; informational only, never an + # admission gate. plotjuggler_sdk_VERSION exists when the plugin builds + # against the installed package; PJ_SDK_VERSION covers the in-tree build. + if(DEFINED plotjuggler_sdk_VERSION) + set(_pj_built_with_sdk "${plotjuggler_sdk_VERSION}") + else() + set(_pj_built_with_sdk "${PJ_SDK_VERSION}") + endif() + string(JSON _sidecar_json SET "${_sidecar_json}" "built_with_sdk" "\"${_pj_built_with_sdk}\"") set(_sidecar_path "${CMAKE_CURRENT_BINARY_DIR}/${TARGET}.pjmanifest.json") file(CONFIGURE OUTPUT "${_sidecar_path}" CONTENT "${_sidecar_json}\n" @ONLY) add_custom_command( diff --git a/pj_base/feature_floors.json b/pj_base/feature_floors.json new file mode 100644 index 00000000..62742e97 --- /dev/null +++ b/pj_base/feature_floors.json @@ -0,0 +1,145 @@ +{ + "doc": "Host-contract surfaces and the SDK release that introduced each; the authority a plugin floor checker validates min_sdk_required against. Identifiers are the C++ wrapper/macro tokens a source scanner matches; 'declaration' names the underlying ABI entity (scanners should match both the identifier and the declaration's member name). Surfaces present at or before minimum_supported_floor live in 'baseline'; floors below minimum_supported_floor are rejected until older history is classified. negotiated=true means the SDK wrapper degrades cleanly on hosts that predate the surface (tail-slot struct_size gating), making the surface eligible for a plugin's graceful-degradation exception; negotiated=false always forces the floor. Client-side static-library helpers are deliberately absent - they compile into the plugin and never constrain the host floor.", + "schema_version": 1, + "minimum_supported_floor": "0.28.0", + "surfaces": { + "completeIngest": { + "since": "0.30.0", + "negotiated": true, + "declaration": "PJ_data_source_runtime_host_vtable_t::complete_ingest" + }, + "discardParserIngest": { + "since": "0.30.0", + "negotiated": true, + "declaration": "PJ_toolbox_runtime_host_vtable_t::discard_parser_ingest" + }, + "setDatasetMetadata": { + "since": "0.32.0", + "negotiated": true, + "declaration": "PJ_data_source_runtime_host_vtable_t::set_dataset_metadata" + }, + "PJ_INGEST_COMPLETION_FLAG_ATTESTS_EMPTY_TOPICS": { + "since": "0.30.0", + "negotiated": true, + "declaration": "PJ_INGEST_COMPLETION_FLAG_ATTESTS_EMPTY_TOPICS" + }, + "PJ_INGEST_COMPLETION_FLAG_NONE": { + "since": "0.30.0", + "negotiated": true, + "declaration": "PJ_INGEST_COMPLETION_FLAG_NONE" + }, + "PJ_INGEST_COMPLETION_FLAGS_V1_MASK": { + "since": "0.30.0", + "negotiated": true, + "declaration": "PJ_INGEST_COMPLETION_FLAGS_V1_MASK" + } + }, + "baseline": [ + "PJ_DATA_PROCESSOR_FLAG_EPHEMERAL", + "PJ_DESCRIPTOR_IMPORT_START_FLAGS_V1_MASK", + "PJ_DESCRIPTOR_IMPORT_START_FLAG_NONE", + "PJ_MODULE_PARSE_INPUT_FLAG_HAS_TIMESTAMP", + "PJ_PARSER_ROUTE_FLAG_OBJECT_V1", + "PJ_PARSER_ROUTE_FLAG_SCALAR_V1", + "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", + "PJ_data_processors_host_vtable_t::remove_data_processor", + "PJ_data_processors_host_vtable_t::validate_data_processor_script", + "PJ_data_source_runtime_host_vtable_t::attach_source_record", + "PJ_data_source_runtime_host_vtable_t::ensure_parser_binding", + "PJ_data_source_runtime_host_vtable_t::is_stop_requested", + "PJ_data_source_runtime_host_vtable_t::list_available_encodings", + "PJ_data_source_runtime_host_vtable_t::notify_available_topics", + "PJ_data_source_runtime_host_vtable_t::notify_state", + "PJ_data_source_runtime_host_vtable_t::progress_finish", + "PJ_data_source_runtime_host_vtable_t::progress_start", + "PJ_data_source_runtime_host_vtable_t::progress_update", + "PJ_data_source_runtime_host_vtable_t::push_message", + "PJ_data_source_runtime_host_vtable_t::report_message", + "PJ_data_source_runtime_host_vtable_t::request_stop", + "PJ_data_source_runtime_host_vtable_t::show_message_box", + "PJ_object_read_host_vtable_t::entry_count", + "PJ_object_read_host_vtable_t::get_bytes", + "PJ_object_read_host_vtable_t::list_topics", + "PJ_object_read_host_vtable_t::lookup_topic", + "PJ_object_read_host_vtable_t::lookup_topic_on_dataset", + "PJ_object_read_host_vtable_t::read_latest_at", + "PJ_object_read_host_vtable_t::release_bytes", + "PJ_object_read_host_vtable_t::time_range", + "PJ_object_read_host_vtable_t::topic_metadata", + "PJ_object_write_host_vtable_t::fetch_ctx_destroy", + "PJ_object_write_host_vtable_t::push_lazy", + "PJ_object_write_host_vtable_t::push_owned", + "PJ_object_write_host_vtable_t::register_topic", + "PJ_object_write_host_vtable_t::set_retention_budget", + "PJ_parser_object_write_host_vtable_t::fetch_ctx_destroy", + "PJ_parser_object_write_host_vtable_t::push_lazy", + "PJ_parser_object_write_host_vtable_t::push_owned", + "PJ_parser_runtime_host_vtable_t::report_diagnostic", + "PJ_parser_write_host_vtable_t::append_arrow_stream", + "PJ_parser_write_host_vtable_t::append_bound_record", + "PJ_parser_write_host_vtable_t::append_record", + "PJ_parser_write_host_vtable_t::ensure_field", + "PJ_playback_host_vtable_t::get_state", + "PJ_playback_host_vtable_t::pause", + "PJ_playback_host_vtable_t::play", + "PJ_playback_host_vtable_t::seek", + "PJ_playback_host_vtable_t::set_playback_rate", + "PJ_playback_host_vtable_t::to_display_time", + "PJ_playback_host_vtable_t::to_display_time_for_source", + "PJ_plot_tab_host_vtable_t::add_curve", + "PJ_plot_tab_host_vtable_t::clear_tab", + "PJ_plot_tab_host_vtable_t::close_tab", + "PJ_plot_tab_host_vtable_t::create_tab", + "PJ_plot_tab_host_vtable_t::list_tab_ids", + "PJ_plot_tab_host_vtable_t::remove_curve", + "PJ_plot_tab_host_vtable_t::tab_config", + "PJ_service_registry_vtable_t::get_service", + "PJ_source_promotion_host_vtable_t::promote_to_file_source", + "PJ_source_write_host_vtable_t::append_arrow_stream", + "PJ_source_write_host_vtable_t::append_bound_record", + "PJ_source_write_host_vtable_t::append_record", + "PJ_source_write_host_vtable_t::ensure_field", + "PJ_source_write_host_vtable_t::ensure_topic", + "PJ_toolbox_host_vtable_t::acquire_catalog_snapshot", + "PJ_toolbox_host_vtable_t::append_arrow_stream", + "PJ_toolbox_host_vtable_t::append_bound_record", + "PJ_toolbox_host_vtable_t::append_record", + "PJ_toolbox_host_vtable_t::create_data_source", + "PJ_toolbox_host_vtable_t::ensure_field", + "PJ_toolbox_host_vtable_t::ensure_topic", + "PJ_toolbox_host_vtable_t::push_owned_object", + "PJ_toolbox_host_vtable_t::read_series_arrow", + "PJ_toolbox_host_vtable_t::register_object_topic", + "PJ_toolbox_host_vtable_t::register_object_topic_on_dataset", + "PJ_toolbox_host_vtable_t::set_object_topic_retention", + "PJ_toolbox_runtime_host_vtable_t::create_parser_ingest", + "PJ_toolbox_runtime_host_vtable_t::notify_data_changed", + "PJ_toolbox_runtime_host_vtable_t::release_parser_ingest", + "PJ_toolbox_runtime_host_vtable_t::report_message", + "PJ_viewport_host_vtable_t::zoom_reset", + "PJ_viewport_host_vtable_t::zoom_to_time_range", + "pj.colormap.v1", + "pj.data_processors.v1", + "pj.descriptor_import.v1", + "pj.parser_functional.v1", + "pj.parser_functional.v2", + "pj.parser_object_write.v1", + "pj.parser_route_claims.v1", + "pj.parser_runtime.v1", + "pj.parser_write.v1", + "pj.playback.v1", + "pj.plot_tabs.v1", + "pj.runtime.v1", + "pj.settings.v1", + "pj.source_object_write.v1", + "pj.source_promotion.v1", + "pj.source_write.v1", + "pj.toolbox_object_read.v1", + "pj.toolbox_runtime.v1", + "pj.toolbox_write.v1", + "pj.topic_subscription.v1", + "pj.viewport.v1" + ] +} diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index d039c523..2e511cdc 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -5,6 +5,14 @@ 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 +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 +validate `min_sdk_required` against. + 1. **Boot-level ABI symbol.** Every plugin .so exports `pj_plugin_abi_version` as a `uint32_t` symbol independent of any vtable. The host `dlsym`s it BEFORE fetching the family vtable; diff --git a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp index cd9fbbc0..2f1bb61b 100644 --- a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp +++ b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp @@ -55,6 +55,11 @@ struct PluginDescriptor { /// Optional SDK contract floor; "" means undeclared. This is a hard host-side /// load gate, distinct from both the application release and the compile SDK. std::string min_sdk_required; + /// Optional SDK version this plugin was COMPILED against ("" in manifests + /// that predate the field). Informational provenance for host diagnostics + /// only — never an admission criterion: min_sdk_required stays the only + /// compatibility gate. + std::string built_with_sdk; }; /// Diagnostic for a candidate DSO that could not produce a valid descriptor. diff --git a/pj_plugins/src/plugin_catalog.cpp b/pj_plugins/src/plugin_catalog.cpp index dbde92e5..1907f90b 100644 --- a/pj_plugins/src/plugin_catalog.cpp +++ b/pj_plugins/src/plugin_catalog.cpp @@ -257,6 +257,10 @@ Expected decodeManifest( if (!min_plotjuggler_version) { return unexpected(min_plotjuggler_version.error()); } + auto built_with_sdk = optional_string("built_with_sdk"); + if (!built_with_sdk) { + return unexpected(built_with_sdk.error()); + } auto min_sdk_required = optional_string("min_sdk_required"); if (!min_sdk_required) { return unexpected(min_sdk_required.error()); @@ -283,6 +287,7 @@ Expected decodeManifest( d.category = *category; d.min_plotjuggler_version = *min_plotjuggler_version; d.min_sdk_required = *min_sdk_required; + d.built_with_sdk = *built_with_sdk; d.file_extensions = *file_extensions; d.capabilities = *capabilities; diff --git a/pj_plugins/tests/plugin_catalog_test.cpp b/pj_plugins/tests/plugin_catalog_test.cpp index 89d4fb2b..cb75e241 100644 --- a/pj_plugins/tests/plugin_catalog_test.cpp +++ b/pj_plugins/tests/plugin_catalog_test.cpp @@ -280,6 +280,21 @@ TEST_F(PluginCatalogTest, MinSdkRequiredDefaultsToEmptyWhenAbsent) { EXPECT_TRUE(descriptor->min_sdk_required.empty()); } +TEST_F(PluginCatalogTest, BuiltWithSdkRoundTripsAndDefaultsEmpty) { + // Informational compile-SDK provenance: round-trips when present, and a + // manifest that predates the field still decodes (back-compat). + auto with_field = decodeManifest( + "static:sdk-test", PluginFamily::kDataSource, + R"({"id":"sdk-test","name":"SDK Test","version":"1.0.0","built_with_sdk":"0.33.0"})"); + ASSERT_TRUE(with_field.has_value()) << with_field.error(); + EXPECT_EQ(with_field->built_with_sdk, "0.33.0"); + + auto without_field = decodeManifest( + "static:sdk-test", PluginFamily::kDataSource, R"({"id":"sdk-test","name":"SDK Test","version":"1.0.0"})"); + ASSERT_TRUE(without_field.has_value()) << without_field.error(); + EXPECT_TRUE(without_field->built_with_sdk.empty()); +} + TEST_F(PluginCatalogTest, MinSdkRequiredAcceptsEmptyString) { auto descriptor = decodeManifest( "static:sdk-test", PluginFamily::kDataSource, diff --git a/test_sdk_install.sh b/test_sdk_install.sh index cc50eb04..024fa857 100755 --- a/test_sdk_install.sh +++ b/test_sdk_install.sh @@ -140,5 +140,19 @@ if nm -C "$STAGING_DIR/lib/libpj_base.a" | grep -E " [TWuVvW] " | grep -q "fmt:: exit 1 fi +# --------------------------------------------------------------------------- +# 7. Verify the feature-floors table is installed and parses +# --------------------------------------------------------------------------- + +echo "" +echo "--- Step 7: Verify feature_floors.json is installed and parses ---" + +FLOORS="$STAGING_DIR/share/plotjuggler_sdk/feature_floors.json" +if [[ ! -f "$FLOORS" ]]; then + echo "ERROR: $FLOORS not installed" + exit 1 +fi +python3 -c "import json,sys; d=json.load(open(sys.argv[1])); assert d['schema_version'] == 1 and d['surfaces'], 'malformed feature_floors.json'" "$FLOORS" + echo "" echo "=== plotjuggler_sdk install test PASSED ===" diff --git a/tools/feature_floors/__pycache__/extract_host_surfaces.cpython-314.pyc b/tools/feature_floors/__pycache__/extract_host_surfaces.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0c90a1853dae1830af6d9d7a0bfcedf7c70f66e6 GIT binary patch literal 6771 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 tuple[int, ...]: + return tuple(int(part) for part in text.split(".")) + + +def main() -> int: + repo = Path(__file__).resolve().parents[2] + errors: list[str] = [] + + inventory = extract(repo) + snapshot_path = repo / "tools/feature_floors/host_surfaces.snapshot" + snapshot = snapshot_path.read_text().split() + if inventory != snapshot: + added = sorted(set(inventory) - set(snapshot)) + removed = sorted(set(snapshot) - set(inventory)) + for surface in added: + errors.append( + f"NEW host surface not classified: {surface}\n" + f" -> add it to pj_base/feature_floors.json (a 'surfaces' entry with its " + f"introducing release, or 'baseline' if it predates the minimum supported floor)\n" + f" -> then refresh the snapshot: tools/feature_floors/extract_host_surfaces.py --write" + ) + for surface in removed: + errors.append(f"host surface removed from headers but still in snapshot: {surface}") + + table_path = repo / "pj_base/feature_floors.json" + table = json.loads(table_path.read_text()) + sdk_version = (repo / "VERSION").read_text().strip() + floor_min = table.get("minimum_supported_floor", "") + if not VERSION_RE.match(floor_min): + errors.append(f"minimum_supported_floor malformed: {floor_min!r}") + + surfaces = table.get("surfaces", {}) + baseline = table.get("baseline", []) + declarations = set() + unreleased: list[str] = [] + for identifier, entry in surfaces.items(): + since = entry.get("since", "") + declaration = entry.get("declaration", "") + if not identifier: + errors.append("empty surface identifier") + if not VERSION_RE.match(since): + errors.append(f"{identifier}: malformed since {since!r}") + else: + if VERSION_RE.match(floor_min) and version_tuple(since) < version_tuple(floor_min): + errors.append(f"{identifier}: since {since} below minimum_supported_floor {floor_min}") + # since may exceed VERSION during development: a surface added for + # the next, not-yet-released version. That requires the changelog's + # unreleased section to declare the host-contract extension. + if version_tuple(since) > version_tuple(sdk_version): + unreleased.append(identifier) + if not isinstance(entry.get("negotiated"), bool): + errors.append(f"{identifier}: negotiated must be a boolean") + if not declaration: + errors.append(f"{identifier}: empty declaration") + declarations.add(declaration) + + if unreleased: + changelog = (repo / "CHANGELOG.md").read_text() + if "Host contract: extended" not in changelog: + errors.append( + "surfaces newer than VERSION exist (" + ", ".join(sorted(unreleased)) + ") but " + "CHANGELOG.md has no 'Host contract: extended: ...' line in the unreleased section" + ) + + # Every inventoried surface is classified exactly once: as some surface's + # declaration, or as baseline. Nothing is silently ignored. + classified = declarations | set(baseline) + for surface in snapshot: + if surface not in classified: + errors.append(f"snapshot surface neither a declaration nor baseline: {surface}") + for surface in sorted(classified - set(snapshot)): + errors.append(f"classified surface no longer in the snapshot: {surface}") + overlap = declarations & set(baseline) + for surface in sorted(overlap): + errors.append(f"surface classified twice (declaration AND baseline): {surface}") + + if errors: + print("feature-floors check FAILED:\n" + "\n".join(errors)) + return 1 + print(f"feature-floors check OK: {len(snapshot)} surfaces, {len(surfaces)} above-floor entries") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/feature_floors/extract_host_surfaces.py b/tools/feature_floors/extract_host_surfaces.py new file mode 100644 index 00000000..05da5f36 --- /dev/null +++ b/tools/feature_floors/extract_host_surfaces.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# Copyright 2026 Davide Faconti +# SPDX-License-Identifier: Apache-2.0 +"""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: + + 1. Function-pointer members of host-side vtables (structs named + PJ_*host_vtable_t, plus PJ_service_registry_vtable_t) in + pj_base/include/pj_base/*.h — emitted as "Struct::member". + 2. Versioned service/extension id strings ("pj..v") in the + pj_base and pj_plugins public headers. Diagnostic-code strings and the + "pj.experimental." prefix don't version a host contract and are excluded + by the .v suffix rule. + 3. PJ_*_FLAG* macros in the protocol headers (wire-contract flags a host + must understand). + +Plugin-side vtables (PJ_data_source_vtable_t, PJ_dialog_vtable_t, ...) are +deliberately absent: the HOST calls those, so an old host simply not calling +a new plugin slot degrades by construction and never constrains a plugin's +min_sdk_required. Client-side static-library helpers are equally absent — +they travel inside the plugin binary. + +Run with --write to refresh host_surfaces.snapshot; CI diffs a fresh +extraction against the checked-in snapshot (see check_feature_floors.py). +""" + +import argparse +import re +import sys +from pathlib import Path + +HOST_VTABLE_RE = re.compile(r"typedef struct (PJ_\w*host_vtable_t|PJ_service_registry_vtable_t)\s*\{") +# One function-pointer member: " (*name)(...)" possibly split over lines. +MEMBER_RE = re.compile(r"\(\s*\*\s*(\w+)\s*\)\s*\(") +SERVICE_ID_RE = re.compile(r'"(pj\.[a-z_0-9.]+\.v\d+)"') +FLAG_RE = re.compile(r"#define\s+(PJ_\w*_FLAG\w*)\s") + +PROTOCOL_HEADER_GLOBS = [ + ("pj_base/include/pj_base", "*.h"), +] +SERVICE_ID_GLOBS = [ + ("pj_base/include/pj_base", "**/*.h*"), + ("pj_plugins/include", "**/*.h*"), + ("pj_plugins/dialog_protocol/include", "**/*.h*"), +] + + +def strip_comments(text: str) -> str: + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + return re.sub(r"//[^\n]*", "", text) + + +def vtable_members(text: str) -> list[str]: + surfaces = [] + for match in HOST_VTABLE_RE.finditer(text): + struct_name = match.group(1) + # Body runs to the closing "} ;" — find the matching brace by depth. + depth = 0 + start = text.index("{", match.start()) + for pos in range(start, len(text)): + if text[pos] == "{": + depth += 1 + elif text[pos] == "}": + depth -= 1 + if depth == 0: + body = text[start:pos] + break + else: + raise SystemExit(f"unterminated struct {struct_name}") + surfaces.extend(f"{struct_name}::{m}" for m in MEMBER_RE.findall(body)) + return surfaces + + +def extract(repo: Path) -> list[str]: + surfaces: set[str] = set() + for rel, glob in PROTOCOL_HEADER_GLOBS: + for header in sorted((repo / rel).glob(glob)): + text = strip_comments(header.read_text()) + surfaces.update(vtable_members(text)) + surfaces.update(FLAG_RE.findall(text)) + for rel, glob in SERVICE_ID_GLOBS: + for header in sorted((repo / rel).glob(glob)): + surfaces.update(SERVICE_ID_RE.findall(strip_comments(header.read_text()))) + return sorted(surfaces) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument("--write", action="store_true", help="refresh host_surfaces.snapshot") + args = parser.parse_args() + + inventory = extract(args.repo) + snapshot = args.repo / "tools/feature_floors/host_surfaces.snapshot" + if args.write: + snapshot.write_text("\n".join(inventory) + "\n") + print(f"wrote {len(inventory)} surfaces to {snapshot}") + return 0 + sys.stdout.write("\n".join(inventory) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/feature_floors/host_surfaces.snapshot b/tools/feature_floors/host_surfaces.snapshot new file mode 100644 index 00000000..312a3ba7 --- /dev/null +++ b/tools/feature_floors/host_surfaces.snapshot @@ -0,0 +1,112 @@ +PJ_DATA_PROCESSOR_FLAG_EPHEMERAL +PJ_DESCRIPTOR_IMPORT_START_FLAGS_V1_MASK +PJ_DESCRIPTOR_IMPORT_START_FLAG_NONE +PJ_INGEST_COMPLETION_FLAGS_V1_MASK +PJ_INGEST_COMPLETION_FLAG_ATTESTS_EMPTY_TOPICS +PJ_INGEST_COMPLETION_FLAG_NONE +PJ_MODULE_PARSE_INPUT_FLAG_HAS_TIMESTAMP +PJ_PARSER_ROUTE_FLAG_OBJECT_V1 +PJ_PARSER_ROUTE_FLAG_SCALAR_V1 +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 +PJ_data_processors_host_vtable_t::remove_data_processor +PJ_data_processors_host_vtable_t::validate_data_processor_script +PJ_data_source_runtime_host_vtable_t::attach_source_record +PJ_data_source_runtime_host_vtable_t::complete_ingest +PJ_data_source_runtime_host_vtable_t::ensure_parser_binding +PJ_data_source_runtime_host_vtable_t::is_stop_requested +PJ_data_source_runtime_host_vtable_t::list_available_encodings +PJ_data_source_runtime_host_vtable_t::notify_available_topics +PJ_data_source_runtime_host_vtable_t::notify_state +PJ_data_source_runtime_host_vtable_t::progress_finish +PJ_data_source_runtime_host_vtable_t::progress_start +PJ_data_source_runtime_host_vtable_t::progress_update +PJ_data_source_runtime_host_vtable_t::push_message +PJ_data_source_runtime_host_vtable_t::report_message +PJ_data_source_runtime_host_vtable_t::request_stop +PJ_data_source_runtime_host_vtable_t::set_dataset_metadata +PJ_data_source_runtime_host_vtable_t::show_message_box +PJ_object_read_host_vtable_t::entry_count +PJ_object_read_host_vtable_t::get_bytes +PJ_object_read_host_vtable_t::list_topics +PJ_object_read_host_vtable_t::lookup_topic +PJ_object_read_host_vtable_t::lookup_topic_on_dataset +PJ_object_read_host_vtable_t::read_latest_at +PJ_object_read_host_vtable_t::release_bytes +PJ_object_read_host_vtable_t::time_range +PJ_object_read_host_vtable_t::topic_metadata +PJ_object_write_host_vtable_t::fetch_ctx_destroy +PJ_object_write_host_vtable_t::push_lazy +PJ_object_write_host_vtable_t::push_owned +PJ_object_write_host_vtable_t::register_topic +PJ_object_write_host_vtable_t::set_retention_budget +PJ_parser_object_write_host_vtable_t::fetch_ctx_destroy +PJ_parser_object_write_host_vtable_t::push_lazy +PJ_parser_object_write_host_vtable_t::push_owned +PJ_parser_runtime_host_vtable_t::report_diagnostic +PJ_parser_write_host_vtable_t::append_arrow_stream +PJ_parser_write_host_vtable_t::append_bound_record +PJ_parser_write_host_vtable_t::append_record +PJ_parser_write_host_vtable_t::ensure_field +PJ_playback_host_vtable_t::get_state +PJ_playback_host_vtable_t::pause +PJ_playback_host_vtable_t::play +PJ_playback_host_vtable_t::seek +PJ_playback_host_vtable_t::set_playback_rate +PJ_playback_host_vtable_t::to_display_time +PJ_playback_host_vtable_t::to_display_time_for_source +PJ_plot_tab_host_vtable_t::add_curve +PJ_plot_tab_host_vtable_t::clear_tab +PJ_plot_tab_host_vtable_t::close_tab +PJ_plot_tab_host_vtable_t::create_tab +PJ_plot_tab_host_vtable_t::list_tab_ids +PJ_plot_tab_host_vtable_t::remove_curve +PJ_plot_tab_host_vtable_t::tab_config +PJ_service_registry_vtable_t::get_service +PJ_source_promotion_host_vtable_t::promote_to_file_source +PJ_source_write_host_vtable_t::append_arrow_stream +PJ_source_write_host_vtable_t::append_bound_record +PJ_source_write_host_vtable_t::append_record +PJ_source_write_host_vtable_t::ensure_field +PJ_source_write_host_vtable_t::ensure_topic +PJ_toolbox_host_vtable_t::acquire_catalog_snapshot +PJ_toolbox_host_vtable_t::append_arrow_stream +PJ_toolbox_host_vtable_t::append_bound_record +PJ_toolbox_host_vtable_t::append_record +PJ_toolbox_host_vtable_t::create_data_source +PJ_toolbox_host_vtable_t::ensure_field +PJ_toolbox_host_vtable_t::ensure_topic +PJ_toolbox_host_vtable_t::push_owned_object +PJ_toolbox_host_vtable_t::read_series_arrow +PJ_toolbox_host_vtable_t::register_object_topic +PJ_toolbox_host_vtable_t::register_object_topic_on_dataset +PJ_toolbox_host_vtable_t::set_object_topic_retention +PJ_toolbox_runtime_host_vtable_t::create_parser_ingest +PJ_toolbox_runtime_host_vtable_t::discard_parser_ingest +PJ_toolbox_runtime_host_vtable_t::notify_data_changed +PJ_toolbox_runtime_host_vtable_t::release_parser_ingest +PJ_toolbox_runtime_host_vtable_t::report_message +PJ_viewport_host_vtable_t::zoom_reset +PJ_viewport_host_vtable_t::zoom_to_time_range +pj.colormap.v1 +pj.data_processors.v1 +pj.descriptor_import.v1 +pj.parser_functional.v1 +pj.parser_functional.v2 +pj.parser_object_write.v1 +pj.parser_route_claims.v1 +pj.parser_runtime.v1 +pj.parser_write.v1 +pj.playback.v1 +pj.plot_tabs.v1 +pj.runtime.v1 +pj.settings.v1 +pj.source_object_write.v1 +pj.source_promotion.v1 +pj.source_write.v1 +pj.toolbox_object_read.v1 +pj.toolbox_runtime.v1 +pj.toolbox_write.v1 +pj.topic_subscription.v1 +pj.viewport.v1 From bf16675095e51d6b6bd958b68de0bef52718c37b Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 6 Sep 2026 23:07:20 +0200 Subject: [PATCH 2/5] refactor: compact version-grouped table shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces group under since/; an entry maps identifier -> declaration ("" when the declaration IS the identifier, as with flag macros); negotiated defaults to true, a hard surface uses the object form. Groups at or below minimum_supported_floor are rejected — those belong in baseline. No doc blob: the schema is documented by the validator and the release-policy docs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTJvEsHX26rG4cHmqppvQg --- pj_base/feature_floors.json | 39 ++++----------- tools/feature_floors/check_feature_floors.py | 50 +++++++++++++------- 2 files changed, 41 insertions(+), 48 deletions(-) diff --git a/pj_base/feature_floors.json b/pj_base/feature_floors.json index 62742e97..12ccb3c1 100644 --- a/pj_base/feature_floors.json +++ b/pj_base/feature_floors.json @@ -1,37 +1,16 @@ { - "doc": "Host-contract surfaces and the SDK release that introduced each; the authority a plugin floor checker validates min_sdk_required against. Identifiers are the C++ wrapper/macro tokens a source scanner matches; 'declaration' names the underlying ABI entity (scanners should match both the identifier and the declaration's member name). Surfaces present at or before minimum_supported_floor live in 'baseline'; floors below minimum_supported_floor are rejected until older history is classified. negotiated=true means the SDK wrapper degrades cleanly on hosts that predate the surface (tail-slot struct_size gating), making the surface eligible for a plugin's graceful-degradation exception; negotiated=false always forces the floor. Client-side static-library helpers are deliberately absent - they compile into the plugin and never constrain the host floor.", "schema_version": 1, "minimum_supported_floor": "0.28.0", - "surfaces": { - "completeIngest": { - "since": "0.30.0", - "negotiated": true, - "declaration": "PJ_data_source_runtime_host_vtable_t::complete_ingest" + "since": { + "0.30.0": { + "PJ_INGEST_COMPLETION_FLAGS_V1_MASK": "", + "PJ_INGEST_COMPLETION_FLAG_ATTESTS_EMPTY_TOPICS": "", + "PJ_INGEST_COMPLETION_FLAG_NONE": "", + "completeIngest": "PJ_data_source_runtime_host_vtable_t::complete_ingest", + "discardParserIngest": "PJ_toolbox_runtime_host_vtable_t::discard_parser_ingest" }, - "discardParserIngest": { - "since": "0.30.0", - "negotiated": true, - "declaration": "PJ_toolbox_runtime_host_vtable_t::discard_parser_ingest" - }, - "setDatasetMetadata": { - "since": "0.32.0", - "negotiated": true, - "declaration": "PJ_data_source_runtime_host_vtable_t::set_dataset_metadata" - }, - "PJ_INGEST_COMPLETION_FLAG_ATTESTS_EMPTY_TOPICS": { - "since": "0.30.0", - "negotiated": true, - "declaration": "PJ_INGEST_COMPLETION_FLAG_ATTESTS_EMPTY_TOPICS" - }, - "PJ_INGEST_COMPLETION_FLAG_NONE": { - "since": "0.30.0", - "negotiated": true, - "declaration": "PJ_INGEST_COMPLETION_FLAG_NONE" - }, - "PJ_INGEST_COMPLETION_FLAGS_V1_MASK": { - "since": "0.30.0", - "negotiated": true, - "declaration": "PJ_INGEST_COMPLETION_FLAGS_V1_MASK" + "0.32.0": { + "setDatasetMetadata": "PJ_data_source_runtime_host_vtable_t::set_dataset_metadata" } }, "baseline": [ diff --git a/tools/feature_floors/check_feature_floors.py b/tools/feature_floors/check_feature_floors.py index 01e9f099..d3240810 100644 --- a/tools/feature_floors/check_feature_floors.py +++ b/tools/feature_floors/check_feature_floors.py @@ -36,8 +36,8 @@ def main() -> int: for surface in added: errors.append( f"NEW host surface not classified: {surface}\n" - f" -> add it to pj_base/feature_floors.json (a 'surfaces' entry with its " - f"introducing release, or 'baseline' if it predates the minimum supported floor)\n" + f" -> add it to pj_base/feature_floors.json (under 'since'/, " + f"or 'baseline' if it predates the minimum supported floor)\n" f" -> then refresh the snapshot: tools/feature_floors/extract_host_surfaces.py --write" ) for surface in removed: @@ -50,30 +50,44 @@ def main() -> int: if not VERSION_RE.match(floor_min): errors.append(f"minimum_supported_floor malformed: {floor_min!r}") - surfaces = table.get("surfaces", {}) + since_groups = table.get("since", {}) baseline = table.get("baseline", []) declarations = set() unreleased: list[str] = [] - for identifier, entry in surfaces.items(): - since = entry.get("since", "") - declaration = entry.get("declaration", "") - if not identifier: - errors.append("empty surface identifier") + surface_count = 0 + for since, group in since_groups.items(): if not VERSION_RE.match(since): - errors.append(f"{identifier}: malformed since {since!r}") - else: - if VERSION_RE.match(floor_min) and version_tuple(since) < version_tuple(floor_min): - errors.append(f"{identifier}: since {since} below minimum_supported_floor {floor_min}") + errors.append(f"malformed since group {since!r}") + continue + if VERSION_RE.match(floor_min) and version_tuple(since) <= version_tuple(floor_min): + errors.append(f"since group {since} at or below minimum_supported_floor {floor_min} — move to baseline") + if not isinstance(group, dict) or not group: + errors.append(f"since group {since} must be a non-empty object") + continue + for identifier, value in group.items(): + surface_count += 1 + if not identifier: + errors.append(f"empty surface identifier in since group {since}") + # Compact forms: "" (negotiated), "" (declaration IS the + # identifier), or {"declaration": ..., "negotiated": false}. + if isinstance(value, str): + declaration = value or identifier + elif isinstance(value, dict): + unknown = set(value) - {"declaration", "negotiated"} + if unknown: + errors.append(f"{identifier}: unknown keys {sorted(unknown)}") + declaration = value.get("declaration") or identifier + if not isinstance(value.get("negotiated", True), bool): + errors.append(f"{identifier}: negotiated must be a boolean") + else: + errors.append(f"{identifier}: entry must be a declaration string or an object") + continue + declarations.add(declaration) # since may exceed VERSION during development: a surface added for # the next, not-yet-released version. That requires the changelog's # unreleased section to declare the host-contract extension. if version_tuple(since) > version_tuple(sdk_version): unreleased.append(identifier) - if not isinstance(entry.get("negotiated"), bool): - errors.append(f"{identifier}: negotiated must be a boolean") - if not declaration: - errors.append(f"{identifier}: empty declaration") - declarations.add(declaration) if unreleased: changelog = (repo / "CHANGELOG.md").read_text() @@ -98,7 +112,7 @@ def main() -> int: if errors: print("feature-floors check FAILED:\n" + "\n".join(errors)) return 1 - print(f"feature-floors check OK: {len(snapshot)} surfaces, {len(surfaces)} above-floor entries") + print(f"feature-floors check OK: {len(snapshot)} surfaces, {surface_count} above-floor entries") return 0 From e937d20cb802edd7ab52e9d562a3693151913221 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 6 Sep 2026 23:11:44 +0200 Subject: [PATCH 3/5] docs(skill): teach plugin builders the min_sdk_required floor discipline The host floor is independent of the build SDK (static-lib helpers never raise it); adopting a host surface newer than the floor means raising it or declaring a tested sdk_floor_exceptions entry. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTJvEsHX26rG4cHmqppvQg --- .claude/skills/plotjuggler-plugin/SKILL.md | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.claude/skills/plotjuggler-plugin/SKILL.md b/.claude/skills/plotjuggler-plugin/SKILL.md index 93d737ef..dcfe0645 100644 --- a/.claude/skills/plotjuggler-plugin/SKILL.md +++ b/.claude/skills/plotjuggler-plugin/SKILL.md @@ -168,6 +168,41 @@ The host routes payloads by these case-sensitive names. DataSource may add `"file_extensions": [".csv"]`. `pj_emit_plugin_manifest` (the pre-0.25 name) still works but is deprecated. +### `min_sdk_required` — the host floor, not the build pin + +`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 +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 +`share/plotjuggler_sdk/feature_floors.json`. + +When you adopt a surface newer than your floor, pick one: + +- **Raise `min_sdk_required`** to that surface's release — when the feature is + essential to what your plugin is for. +- **Degrade gracefully** — when the wrapper's error return is acceptable + (negotiated tail slots return a `Status` on older hosts; ignore it and carry + on). Then declare it in the manifest and prove it with a test that runs + against a host lacking the slot (the testing fixtures support `struct_size` + truncation): + +```json +"sdk_floor_exceptions": { + "setDatasetMetadata": { + "reason": "optional: metadata display absent on hosts older than 0.32; load unaffected", + "test": "MySuite.ImportSucceedsWithoutMetadataSlot" + } +} +``` + +The official-plugins repo CI enforces exactly this rule (floor ≥ every matched +surface's release, or a validated exception); third-party plugins should follow +the same discipline. `built_with_sdk` is stamped into the sidecar +automatically — informational provenance, never a compatibility gate. + ## Step 6 — The rules that silently break a plugin These cut across all families. The family references add family-specific traps From eaf3db02e34e6ea66e497ee21bf4b74d0343a95e Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 6 Sep 2026 23:45:36 +0200 Subject: [PATCH 4/5] feat: ship the floor-checker core with the package; lean floor contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/feature_floors/feature_floor_check.py is now the single owner of the table schema, the C++ source scan, verdict math, and manifest validation — installed to share/plotjuggler_sdk/ beside feature_floors.json, with a CLI for out-of-tree plugin authors. The CI gate imports it, so a schema change is one atomic edit. The manifest contract is lean: min_sdk_required (every matched host surface above it must be runtime-negotiated; hard surfaces always force the floor), suggested_sdk_version (required and exactly the max introducing release over all matched surfaces when any exceeds the floor, forbidden otherwise), and floor_test (one named test proving the plugin functions against a floor-level host). sdk_floor_exceptions is rejected with a migration message. suggested_sdk_version replaces the never-shipped built_with_sdk manifest stamp — declared and validated rather than build-derived. min_plotjuggler_version is deprecated in the release policy (abi_major is the generation gate); the decoder keeps reading it. Extractor rebuilt on review findings: top-level vtable members only (no callback-parameter ghosts), colormap/settings vtables included, plugin-provided extension ids excluded, typedef/whitespace/#-define spellings handled — 113 surfaces, matching an independent Clang count. The gate counts duplicate declarations, couples host-contract changelog lines to the unreleased section only, and validates the table shape strictly. Install test loads the shipped table through the shipped checker. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTJvEsHX26rG4cHmqppvQg --- .claude/skills/plotjuggler-plugin/SKILL.md | 41 +- CHANGELOG.md | 14 +- CLAUDE.md | 5 + CMakeLists.txt | 8 +- cmake/PjPlugin.cmake | 9 - pj_base/feature_floors.json | 15 +- .../pj_plugins/host/plugin_catalog.hpp | 11 +- pj_plugins/src/plugin_catalog.cpp | 8 +- pj_plugins/tests/plugin_catalog_test.cpp | 12 +- test_sdk_install.sh | 27 +- .../extract_host_surfaces.cpython-314.pyc | Bin 6771 -> 11150 bytes .../feature_floor_check.cpython-314.pyc | Bin 0 -> 30931 bytes tools/feature_floors/check_feature_floors.py | 116 ++-- tools/feature_floors/extract_host_surfaces.py | 170 +++++- tools/feature_floors/feature_floor_check.py | 534 ++++++++++++++++++ tools/feature_floors/host_surfaces.snapshot | 15 +- 16 files changed, 826 insertions(+), 159 deletions(-) create mode 100644 tools/feature_floors/__pycache__/feature_floor_check.cpython-314.pyc create mode 100644 tools/feature_floors/feature_floor_check.py diff --git a/.claude/skills/plotjuggler-plugin/SKILL.md b/.claude/skills/plotjuggler-plugin/SKILL.md index dcfe0645..231a33d6 100644 --- a/.claude/skills/plotjuggler-plugin/SKILL.md +++ b/.claude/skills/plotjuggler-plugin/SKILL.md @@ -181,27 +181,36 @@ is `feature_floors.json`, shipped in the package at When you adopt a surface newer than your floor, pick one: -- **Raise `min_sdk_required`** to that surface's release — when the feature is +- **Raise `min_sdk_required`** to that surface's release — mandatory for a + non-negotiated (hard protocol) surface, and right whenever the feature is essential to what your plugin is for. -- **Degrade gracefully** — when the wrapper's error return is acceptable - (negotiated tail slots return a `Status` on older hosts; ignore it and carry - on). Then declare it in the manifest and prove it with a test that runs - against a host lacking the slot (the testing fixtures support `struct_size` - truncation): +- **Declare the degraded range** — when the surface is runtime-negotiated and + the wrapper's error return is acceptable (ignore it and carry on). Two + manifest fields, required together and forbidden when nothing exceeds the + floor: ```json -"sdk_floor_exceptions": { - "setDatasetMetadata": { - "reason": "optional: metadata display absent on hosts older than 0.32; load unaffected", - "test": "MySuite.ImportSucceedsWithoutMetadataSlot" - } -} +"min_sdk_required": "0.28.0", +"suggested_sdk_version": "0.32.0", +"floor_test": "McapDatasetMetadata.ImportSucceedsAgainstFloorLevelHost" ``` -The official-plugins repo CI enforces exactly this rule (floor ≥ every matched -surface's release, or a validated exception); third-party plugins should follow -the same discipline. `built_with_sdk` is stamped into the sidecar -automatically — informational provenance, never a compatibility gate. +`suggested_sdk_version` is the full-feature floor (exactly the max introducing +release over every host surface you use — the checker validates it), so hosts +can tell users which PlotJuggler unlocks your whole feature set. `floor_test` +names one gtest, existing in your `tests/`, proving the plugin functions +against a floor-level host — drive it with a `struct_size`-truncated host +vtable (the testing fixtures support this). Neither field is ever a gate: +`min_sdk_required` remains the only admission criterion, and +`min_plotjuggler_version` is deprecated. + +Check your own plugin with the tool the SDK package ships next to the table: + +```bash +python3 /share/plotjuggler_sdk/feature_floor_check.py \ + --table /share/plotjuggler_sdk/feature_floors.json \ + --manifest my_plugin/manifest.json --sources my_plugin +``` ## Step 6 — The rules that silently break a plugin diff --git a/CHANGELOG.md b/CHANGELOG.md index 23c35d67..f76077cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is ## [0.33.0] — unreleased Host contract: unchanged (no floor impact) — this release adds the floor -table itself and the `built_with_sdk` manifest stamp, no new host surfaces. +table itself and the `suggested_sdk_version` manifest field, no new host surfaces. - Ship `share/plotjuggler_sdk/feature_floors.json`: the host-contract surface table (surface → introducing release, runtime-negotiated or not) that a @@ -14,11 +14,13 @@ table itself and the `built_with_sdk` manifest stamp, no new host surfaces. surface (tail slot, versioned service id, wire flag) must be classified in the table in the same change — `tools/feature_floors/check_feature_floors.py` runs in CI and fails on any unclassified surface. -- Stamp `built_with_sdk` (the compile-SDK version) into every plugin's - `.pjmanifest.json` sidecar and expose it as an optional - `PluginDescriptor::built_with_sdk`. Informational provenance for host - diagnostics only — never an admission criterion; older manifests without - the field decode unchanged. +- New optional manifest fields `suggested_sdk_version` (decoded into + `PluginDescriptor::suggested_sdk_version`) and `floor_test`: a plugin whose + used surfaces exceed its functioning floor declares the full-feature floor + (validated by the floor checker to equal max introducing release over its + matched surfaces) plus the gtest proving it functions against a floor-level + host. `min_sdk_required` remains the only admission gate; + `min_plotjuggler_version` is deprecated (host-side removal lands in PJ4). ## [0.32.0] diff --git a/CLAUDE.md b/CLAUDE.md index 378a206d..5405d2e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,6 +83,11 @@ contracts) do, and those MUST be classified there in the same PR (CI-enforced by `tools/feature_floors/check_feature_floors.py`). The version stays one monotonic identity; `feature_floors.json` carries the per-release compatibility semantics; `PJ_ABI_VERSION` remains the orthogonal hard-break counter. +The plugin compatibility contract is `min_sdk_required` (the functioning +floor, the only admission gate) plus the informational +`suggested_sdk_version` (full-feature floor) and `abi_major` for hard +breaks; `min_plotjuggler_version` is deprecated — the decoder keeps reading +it for back-compat, and the host-side gate removal happens in PJ4. Changelog convention: every release section carries one `Host contract:` line — either `unchanged (no floor impact)` or `extended: (floor X.Y.Z)`; the floor checker requires the `extended` form whenever a surface newer than diff --git a/CMakeLists.txt b/CMakeLists.txt index 0f14cb08..45421984 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -152,9 +152,11 @@ if(PJ_INSTALL_SDK) DESTINATION share/plotjuggler_sdk/docs ) - # Host-surface floor table: the machine-readable authority a plugin floor - # checker validates min_sdk_required against (see tools/feature_floors/). - install(FILES pj_base/feature_floors.json DESTINATION share/plotjuggler_sdk) + # Host-surface floor table + the reusable checker that consumes it: the + # machine-readable authority (and tool) a plugin validates min_sdk_required + # against (see tools/feature_floors/). + install(FILES pj_base/feature_floors.json tools/feature_floors/feature_floor_check.py + DESTINATION share/plotjuggler_sdk) # Fixture sources come from the authoritative PJ_SDK_TEST_FIXTURES list # (cmake/PjSdkTestFixtures.cmake, included above). diff --git a/cmake/PjPlugin.cmake b/cmake/PjPlugin.cmake index af3d1089..927ebae5 100644 --- a/cmake/PjPlugin.cmake +++ b/cmake/PjPlugin.cmake @@ -342,15 +342,6 @@ function(pj_configure_plugin TARGET) set(_sidecar_json "${_src_json}") string(JSON _sidecar_json SET "${_sidecar_json}" "abi_major" "${ARG_ABI_MAJOR}") string(JSON _sidecar_json SET "${_sidecar_json}" "family" "\"${_primary_family}\"") - # Compile-SDK provenance for host diagnostics; informational only, never an - # admission gate. plotjuggler_sdk_VERSION exists when the plugin builds - # against the installed package; PJ_SDK_VERSION covers the in-tree build. - if(DEFINED plotjuggler_sdk_VERSION) - set(_pj_built_with_sdk "${plotjuggler_sdk_VERSION}") - else() - set(_pj_built_with_sdk "${PJ_SDK_VERSION}") - endif() - string(JSON _sidecar_json SET "${_sidecar_json}" "built_with_sdk" "\"${_pj_built_with_sdk}\"") set(_sidecar_path "${CMAKE_CURRENT_BINARY_DIR}/${TARGET}.pjmanifest.json") file(CONFIGURE OUTPUT "${_sidecar_path}" CONTENT "${_sidecar_json}\n" @ONLY) add_custom_command( diff --git a/pj_base/feature_floors.json b/pj_base/feature_floors.json index 12ccb3c1..92c8c77b 100644 --- a/pj_base/feature_floors.json +++ b/pj_base/feature_floors.json @@ -20,6 +20,8 @@ "PJ_MODULE_PARSE_INPUT_FLAG_HAS_TIMESTAMP", "PJ_PARSER_ROUTE_FLAG_OBJECT_V1", "PJ_PARSER_ROUTE_FLAG_SCALAR_V1", + "PJ_colormap_registry_vtable_t::register_map", + "PJ_colormap_registry_vtable_t::unregister_map", "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", @@ -47,12 +49,10 @@ "PJ_object_read_host_vtable_t::release_bytes", "PJ_object_read_host_vtable_t::time_range", "PJ_object_read_host_vtable_t::topic_metadata", - "PJ_object_write_host_vtable_t::fetch_ctx_destroy", "PJ_object_write_host_vtable_t::push_lazy", "PJ_object_write_host_vtable_t::push_owned", "PJ_object_write_host_vtable_t::register_topic", "PJ_object_write_host_vtable_t::set_retention_budget", - "PJ_parser_object_write_host_vtable_t::fetch_ctx_destroy", "PJ_parser_object_write_host_vtable_t::push_lazy", "PJ_parser_object_write_host_vtable_t::push_owned", "PJ_parser_runtime_host_vtable_t::report_diagnostic", @@ -75,6 +75,12 @@ "PJ_plot_tab_host_vtable_t::remove_curve", "PJ_plot_tab_host_vtable_t::tab_config", "PJ_service_registry_vtable_t::get_service", + "PJ_settings_store_vtable_t::contains", + "PJ_settings_store_vtable_t::get_string", + "PJ_settings_store_vtable_t::get_string_list", + "PJ_settings_store_vtable_t::remove_key", + "PJ_settings_store_vtable_t::set_string", + "PJ_settings_store_vtable_t::set_string_list", "PJ_source_promotion_host_vtable_t::promote_to_file_source", "PJ_source_write_host_vtable_t::append_arrow_stream", "PJ_source_write_host_vtable_t::append_bound_record", @@ -101,11 +107,7 @@ "PJ_viewport_host_vtable_t::zoom_to_time_range", "pj.colormap.v1", "pj.data_processors.v1", - "pj.descriptor_import.v1", - "pj.parser_functional.v1", - "pj.parser_functional.v2", "pj.parser_object_write.v1", - "pj.parser_route_claims.v1", "pj.parser_runtime.v1", "pj.parser_write.v1", "pj.playback.v1", @@ -118,7 +120,6 @@ "pj.toolbox_object_read.v1", "pj.toolbox_runtime.v1", "pj.toolbox_write.v1", - "pj.topic_subscription.v1", "pj.viewport.v1" ] } diff --git a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp index 2f1bb61b..86a02e9c 100644 --- a/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp +++ b/pj_plugins/include/pj_plugins/host/plugin_catalog.hpp @@ -55,11 +55,12 @@ struct PluginDescriptor { /// Optional SDK contract floor; "" means undeclared. This is a hard host-side /// load gate, distinct from both the application release and the compile SDK. std::string min_sdk_required; - /// Optional SDK version this plugin was COMPILED against ("" in manifests - /// that predate the field). Informational provenance for host diagnostics - /// only — never an admission criterion: min_sdk_required stays the only - /// compatibility gate. - std::string built_with_sdk; + /// Optional full-feature floor: the SDK release at which every capability + /// this plugin uses is available ("" in manifests that do not declare it). + /// min_sdk_required remains the FUNCTIONING floor; a host between the two + /// runs the plugin with some optional features inactive. Informational, for + /// host degradation display — never an admission criterion. + std::string suggested_sdk_version; }; /// Diagnostic for a candidate DSO that could not produce a valid descriptor. diff --git a/pj_plugins/src/plugin_catalog.cpp b/pj_plugins/src/plugin_catalog.cpp index 1907f90b..7bff9483 100644 --- a/pj_plugins/src/plugin_catalog.cpp +++ b/pj_plugins/src/plugin_catalog.cpp @@ -257,9 +257,9 @@ Expected decodeManifest( if (!min_plotjuggler_version) { return unexpected(min_plotjuggler_version.error()); } - auto built_with_sdk = optional_string("built_with_sdk"); - if (!built_with_sdk) { - return unexpected(built_with_sdk.error()); + auto suggested_sdk_version = optional_string("suggested_sdk_version"); + if (!suggested_sdk_version) { + return unexpected(suggested_sdk_version.error()); } auto min_sdk_required = optional_string("min_sdk_required"); if (!min_sdk_required) { @@ -287,7 +287,7 @@ Expected decodeManifest( d.category = *category; d.min_plotjuggler_version = *min_plotjuggler_version; d.min_sdk_required = *min_sdk_required; - d.built_with_sdk = *built_with_sdk; + d.suggested_sdk_version = *suggested_sdk_version; d.file_extensions = *file_extensions; d.capabilities = *capabilities; diff --git a/pj_plugins/tests/plugin_catalog_test.cpp b/pj_plugins/tests/plugin_catalog_test.cpp index cb75e241..65a72929 100644 --- a/pj_plugins/tests/plugin_catalog_test.cpp +++ b/pj_plugins/tests/plugin_catalog_test.cpp @@ -280,19 +280,19 @@ TEST_F(PluginCatalogTest, MinSdkRequiredDefaultsToEmptyWhenAbsent) { EXPECT_TRUE(descriptor->min_sdk_required.empty()); } -TEST_F(PluginCatalogTest, BuiltWithSdkRoundTripsAndDefaultsEmpty) { - // Informational compile-SDK provenance: round-trips when present, and a - // manifest that predates the field still decodes (back-compat). +TEST_F(PluginCatalogTest, SuggestedSdkVersionRoundTripsAndDefaultsEmpty) { + // Informational full-feature floor: round-trips when declared, and a + // manifest without the field still decodes (back-compat). auto with_field = decodeManifest( "static:sdk-test", PluginFamily::kDataSource, - R"({"id":"sdk-test","name":"SDK Test","version":"1.0.0","built_with_sdk":"0.33.0"})"); + R"({"id":"sdk-test","name":"SDK Test","version":"1.0.0","suggested_sdk_version":"0.33.0"})"); ASSERT_TRUE(with_field.has_value()) << with_field.error(); - EXPECT_EQ(with_field->built_with_sdk, "0.33.0"); + EXPECT_EQ(with_field->suggested_sdk_version, "0.33.0"); auto without_field = decodeManifest( "static:sdk-test", PluginFamily::kDataSource, R"({"id":"sdk-test","name":"SDK Test","version":"1.0.0"})"); ASSERT_TRUE(without_field.has_value()) << without_field.error(); - EXPECT_TRUE(without_field->built_with_sdk.empty()); + EXPECT_TRUE(without_field->suggested_sdk_version.empty()); } TEST_F(PluginCatalogTest, MinSdkRequiredAcceptsEmptyString) { diff --git a/test_sdk_install.sh b/test_sdk_install.sh index 024fa857..d9c146df 100755 --- a/test_sdk_install.sh +++ b/test_sdk_install.sh @@ -141,18 +141,31 @@ if nm -C "$STAGING_DIR/lib/libpj_base.a" | grep -E " [TWuVvW] " | grep -q "fmt:: fi # --------------------------------------------------------------------------- -# 7. Verify the feature-floors table is installed and parses +# 7. Verify the feature-floors table + checker are installed and work # --------------------------------------------------------------------------- echo "" -echo "--- Step 7: Verify feature_floors.json is installed and parses ---" +echo "--- Step 7: Verify feature_floors.json and feature_floor_check.py install ---" FLOORS="$STAGING_DIR/share/plotjuggler_sdk/feature_floors.json" -if [[ ! -f "$FLOORS" ]]; then - echo "ERROR: $FLOORS not installed" - exit 1 -fi -python3 -c "import json,sys; d=json.load(open(sys.argv[1])); assert d['schema_version'] == 1 and d['surfaces'], 'malformed feature_floors.json'" "$FLOORS" +FLOOR_CHECK="$STAGING_DIR/share/plotjuggler_sdk/feature_floor_check.py" +for f in "$FLOORS" "$FLOOR_CHECK"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: $f not installed" + exit 1 + fi +done +# The installed checker is the one parser of the installed table: loading the +# table through it validates both, in the exact combination users receive. +python3 -c " +import importlib.util, pathlib, sys +spec = importlib.util.spec_from_file_location('feature_floor_check', sys.argv[1]) +module = importlib.util.module_from_spec(spec) +sys.modules['feature_floor_check'] = module +spec.loader.exec_module(module) +table = module.load_surface_table(pathlib.Path(sys.argv[2])) +assert table.surfaces, 'empty feature_floors.json' +" "$FLOOR_CHECK" "$FLOORS" echo "" echo "=== plotjuggler_sdk install test PASSED ===" diff --git a/tools/feature_floors/__pycache__/extract_host_surfaces.cpython-314.pyc b/tools/feature_floors/__pycache__/extract_host_surfaces.cpython-314.pyc index 0c90a1853dae1830af6d9d7a0bfcedf7c70f66e6..a5dd207dedd7e00b12d10fd5699e56f209245b9b 100644 GIT binary patch literal 11150 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 C6#|?9 delta 3231 zcmaJ@U2qfE6~3$gpS3JovSni#twd}j3oqCh48#~rZ7>)tBU8~da+I~Swil$;_U_8q zPCL>}rc;ucl$d1(l9DN%&?$KeGtEQ$(qa10$@FJN9#L>Nw8P{r(+BL5H0fK6u0?M)~z(e?Z7*9-W7KO zPwRYRya9NZ&bw!t;*AHshfro4LVh%3oO)^Xp4TwrR{uzKnwkrro78_&kBi3(VpR%Axxa(u?YkZ0;ts)uN% zs!Azk7b9g0GLg$XUKG-N3iI;<%v|15i7I%2r;+FXCbG0#gKSwmSzQ!!^>*F^=9$7vMli#evV`f z>WB$L@X1Thm(LGhSU5?!C;7%odooOz9w6c5rD%lw=p?+-9tukrlI*Ko?@DSiFrA6A%GRR&U!*Q6H&KY#*DQ0L^~&L;JtGEWpVG ztlgM^TXT`^mQv|_j%=)qJ-X)a9Q!F+wfjz~Wp{fKn?M*bU>liW%0lHwC~Sux-B3t3 zgoz8C43dHEKuYLq)J%Cd3AyRnAfMab+v#7n>k>?ZO1joIfJMj(uMys-_d&{8ONd1h zl|)GGqr=ueAtV_RhO4bD>N_oVOWyh#l8mY=)MiQ;rtIouXj|KWfmi`OQ<4OuKM7N^ zLA^_N7LS75eBQhZdPn_Kxy* zbHECEMrQ~tm=b0RNtW&aI^(vZ?I_t;pAa`rTWQp9sJF)jDnY6DLSApex|fQZpy%0= z&2?p!ae0f0tybzr1GSO*>;Rhh3qr(0g4%&1<}XNQHR=tG`q7HFKpG*`I-o{xAEZ*B zOFh}(z3@Km2Qoy3PylxC96AoUk*H|{jpAUo2+Ge?&r{)@uO`l68&W#*9 zJ(h}(X*PgJMt~G(=6ReiNa{aaJBt>*r)D(!nXxm+#^OZJO7x(0L~NMAM6<~SJjbPZ zSu^K2CB2}TGrXiM;4U&~l=8A>n$2gHF)4PLfTLSKKbWI6Xg$9;+y@#uIQ|UG@HZv2 z-q=(cf7BFs*c4uC3g7WQXkss$*IPTTUtGO-E2xT>osZp3AaQ%IJ60Vx_TDo8%J~cD zhYb}stNy<6$l3Ldz^(L+Bkzv>&D*}&h`iA+n~|rp9N2qz;_l&c=SZdbSeZVyZgXF0 ze5>)kFZRju!@=>j!SPSM_Xj5{zSsj>Y%_qGcW*F=_LgFpAbzfBhUhdC=A}F)h>RF9 z!4+;Hge>w!*gp;9TiAC{{!Ikr+DjYcb93Of@q>f9DaMpt>GC-d@N$Yq$S{8MA?Y>EIvRuOZDQQ zBSa=JsTO^;vf&waYDE;#Y?*n6=%qZNPO1G@jI75VmW8c}QXYX@rWR{H{- zL)Cd_9{T&%{C%IW_gWv0oLw6^TN#VR^O z21k2IMO6!((u{Cpd{KG7qTUSpDf-vH4c<0J$qMxhY3559x{Kz}Kh?aOF2L8HD13;O=( zaS-pMN2gMniC+>F4Cfxw0g4?(U^UxNE}wzB*)ZnemmDkd`#?5~6h&>?e3S_e4a)r) zvVVpge@AVPn>^RIUENmp^OdH#lIt<;x^n96Q)S=Y3O!J=ue+PCJFYs)9fKA3fl|Y| zgTB)9c2BuwpyJq9vTj(AdFO`F&~7NBmVa4H>UflPz_sD49_SxmZBvD4&_pu!UmbQh AQ2+n{ diff --git a/tools/feature_floors/__pycache__/feature_floor_check.cpython-314.pyc b/tools/feature_floors/__pycache__/feature_floor_check.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..470a3989f6478c4f10174c137a76f1cbfb242282 GIT binary patch literal 30931 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 literal 0 HcmV?d00001 diff --git a/tools/feature_floors/check_feature_floors.py b/tools/feature_floors/check_feature_floors.py index d3240810..e75da95c 100644 --- a/tools/feature_floors/check_feature_floors.py +++ b/tools/feature_floors/check_feature_floors.py @@ -4,23 +4,38 @@ """CI gate for the host-surface inventory and feature_floors.json. Fails when a host surface exists in the headers but is not classified, so a -new tail slot / service / flag cannot merge without its floor entry. Also -validates the table itself. Exit 0 = consistent. +new tail slot / service / flag cannot merge without its floor entry. Table +schema validation itself lives in feature_floor_check.py (the shipped, +reusable parser); this gate layers the repo-side obligations on top: +snapshot/classification consistency, unique release assignment, and the +changelog coupling. Exit 0 = consistent. """ -import json import re import sys +from collections import Counter from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from extract_host_surfaces import extract # noqa: E402 +from feature_floor_check import CheckError, format_version, load_surface_table, parse_version # noqa: E402 -VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") +def _unreleased_changelog_section(changelog: str) -> str: + """The text of the unreleased section, or empty when none exists. -def version_tuple(text: str) -> tuple[int, ...]: - return tuple(int(part) for part in text.split(".")) + Restricting the 'Host contract:' obligation to this section keeps a + historical extension line from satisfying a new release's duty. Residual + gap, accepted: bumping VERSION to the surface's release in the same PR + retires the obligation — the release commit is where that is reviewed. + """ + headings = list(re.finditer(r"^## .*$", changelog, flags=re.MULTILINE)) + for index, heading in enumerate(headings): + if "unreleased" in heading.group(0).lower(): + start = heading.end() + end = headings[index + 1].start() if index + 1 < len(headings) else len(changelog) + return changelog[start:end] + return "" def main() -> int: @@ -42,77 +57,60 @@ def main() -> int: ) for surface in removed: errors.append(f"host surface removed from headers but still in snapshot: {surface}") + if not added and not removed: + errors.append("snapshot is stale (ordering): refresh with extract_host_surfaces.py --write") table_path = repo / "pj_base/feature_floors.json" - table = json.loads(table_path.read_text()) - sdk_version = (repo / "VERSION").read_text().strip() - floor_min = table.get("minimum_supported_floor", "") - if not VERSION_RE.match(floor_min): - errors.append(f"minimum_supported_floor malformed: {floor_min!r}") - - since_groups = table.get("since", {}) - baseline = table.get("baseline", []) - declarations = set() - unreleased: list[str] = [] - surface_count = 0 - for since, group in since_groups.items(): - if not VERSION_RE.match(since): - errors.append(f"malformed since group {since!r}") - continue - if VERSION_RE.match(floor_min) and version_tuple(since) <= version_tuple(floor_min): - errors.append(f"since group {since} at or below minimum_supported_floor {floor_min} — move to baseline") - if not isinstance(group, dict) or not group: - errors.append(f"since group {since} must be a non-empty object") - continue - for identifier, value in group.items(): - surface_count += 1 - if not identifier: - errors.append(f"empty surface identifier in since group {since}") - # Compact forms: "" (negotiated), "" (declaration IS the - # identifier), or {"declaration": ..., "negotiated": false}. - if isinstance(value, str): - declaration = value or identifier - elif isinstance(value, dict): - unknown = set(value) - {"declaration", "negotiated"} - if unknown: - errors.append(f"{identifier}: unknown keys {sorted(unknown)}") - declaration = value.get("declaration") or identifier - if not isinstance(value.get("negotiated", True), bool): - errors.append(f"{identifier}: negotiated must be a boolean") - else: - errors.append(f"{identifier}: entry must be a declaration string or an object") - continue - declarations.add(declaration) - # since may exceed VERSION during development: a surface added for - # the next, not-yet-released version. That requires the changelog's - # unreleased section to declare the host-contract extension. - if version_tuple(since) > version_tuple(sdk_version): - unreleased.append(identifier) + sdk_version = parse_version((repo / "VERSION").read_text().strip(), "VERSION file") + try: + table = load_surface_table(table_path) + except CheckError as exc: + print(f"feature-floors check FAILED:\n{exc}") + return 1 + above_floor = [ + surface for surface in table.surfaces.values() if surface.identifier not in table.baseline + ] + + # Unique release assignment: the parser already rejects an identifier in + # two groups and duplicate baseline entries; a declaration reused under a + # second identifier would still assign two versions to one ABI entity. + declaration_counts = Counter(surface.declaration for surface in above_floor) + declaration_counts.update(table.baseline) + for declaration, count in sorted(declaration_counts.items()): + if count > 1: + errors.append(f"surface declaration classified {count} times: {declaration}") + + # since may exceed VERSION during development: a surface added for the + # next, not-yet-released version. That requires the changelog's unreleased + # section to declare the host-contract extension. + unreleased = sorted( + surface.identifier for surface in above_floor if surface.since > sdk_version + ) if unreleased: - changelog = (repo / "CHANGELOG.md").read_text() - if "Host contract: extended" not in changelog: + section = _unreleased_changelog_section((repo / "CHANGELOG.md").read_text()) + if "Host contract: extended" not in section: errors.append( - "surfaces newer than VERSION exist (" + ", ".join(sorted(unreleased)) + ") but " - "CHANGELOG.md has no 'Host contract: extended: ...' line in the unreleased section" + "surfaces newer than VERSION exist (" + ", ".join(unreleased) + ") but the " + "unreleased CHANGELOG.md section has no 'Host contract: extended: ...' line" ) # Every inventoried surface is classified exactly once: as some surface's # declaration, or as baseline. Nothing is silently ignored. - classified = declarations | set(baseline) + classified = {surface.declaration for surface in above_floor} | set(table.baseline) for surface in snapshot: if surface not in classified: errors.append(f"snapshot surface neither a declaration nor baseline: {surface}") for surface in sorted(classified - set(snapshot)): errors.append(f"classified surface no longer in the snapshot: {surface}") - overlap = declarations & set(baseline) - for surface in sorted(overlap): - errors.append(f"surface classified twice (declaration AND baseline): {surface}") if errors: print("feature-floors check FAILED:\n" + "\n".join(errors)) return 1 - print(f"feature-floors check OK: {len(snapshot)} surfaces, {surface_count} above-floor entries") + print( + f"feature-floors check OK: {len(snapshot)} surfaces, {len(above_floor)} above-floor entries " + f"(minimum {format_version(table.minimum_supported_floor)})" + ) return 0 diff --git a/tools/feature_floors/extract_host_surfaces.py b/tools/feature_floors/extract_host_surfaces.py index 05da5f36..5c2f0530 100644 --- a/tools/feature_floors/extract_host_surfaces.py +++ b/tools/feature_floors/extract_host_surfaces.py @@ -6,21 +6,24 @@ The inventory is what feature_floors.json must classify: every surface a plugin can require from the HOST at runtime. Three kinds are extracted: - 1. Function-pointer members of host-side vtables (structs named - PJ_*host_vtable_t, plus PJ_service_registry_vtable_t) in - pj_base/include/pj_base/*.h — emitted as "Struct::member". - 2. Versioned service/extension id strings ("pj..v") in the - pj_base and pj_plugins public headers. Diagnostic-code strings and the - "pj.experimental." prefix don't version a host contract and are excluded - by the .v suffix rule. + 1. Callable members of host-side vtables (top-level function-pointer + members, including typedef'd callable types) — emitted as + "Struct::member". Callback parameters nested inside a member's + parameter list are NOT members and are skipped. + 2. Versioned host-served service ids ("pj..v"). Plugin-provided + extension ids are excluded: the HOST queries those from the plugin, so + an older host simply never asking for a new one degrades by + construction. Every discovered id must be classified in one of the two + lists below — an unknown id fails the extraction. 3. PJ_*_FLAG* macros in the protocol headers (wire-contract flags a host must understand). -Plugin-side vtables (PJ_data_source_vtable_t, PJ_dialog_vtable_t, ...) are -deliberately absent: the HOST calls those, so an old host simply not calling -a new plugin slot degrades by construction and never constrains a plugin's -min_sdk_required. Client-side static-library helpers are equally absent — -they travel inside the plugin binary. +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 +by construction and never constrains a plugin's min_sdk_required. +Client-side static-library helpers are equally absent — they travel inside +the plugin binary. Every discovered vtable must be classified as host-side +or plugin-side below; an unknown one fails the extraction. Run with --write to refresh host_surfaces.snapshot; CI diffs a fresh extraction against the checked-in snapshot (see check_feature_floors.py). @@ -31,11 +34,53 @@ import sys from pathlib import Path -HOST_VTABLE_RE = re.compile(r"typedef struct (PJ_\w*host_vtable_t|PJ_service_registry_vtable_t)\s*\{") -# One function-pointer member: " (*name)(...)" possibly split over lines. -MEMBER_RE = re.compile(r"\(\s*\*\s*(\w+)\s*\)\s*\(") +# Host-side vtables beyond the PJ_*host_vtable_t naming convention. +EXTRA_HOST_VTABLES = { + "PJ_service_registry_vtable_t", + "PJ_colormap_registry_vtable_t", + "PJ_settings_store_vtable_t", +} +# Plugin-implemented vtables: the host calls these, they never constrain floors. +PLUGIN_SIDE_VTABLES = { + "PJ_data_source_vtable_t", + "PJ_message_parser_vtable_t", + "PJ_toolbox_vtable_t", + "PJ_joinable_job_vtable_t", +} +# Versioned ids the HOST serves through the service registry (host surfaces). +HOST_SERVICE_IDS = { + "pj.colormap.v1", + "pj.data_processors.v1", + "pj.parser_object_write.v1", + "pj.parser_runtime.v1", + "pj.parser_write.v1", + "pj.playback.v1", + "pj.plot_tabs.v1", + "pj.runtime.v1", + "pj.settings.v1", + "pj.source_object_write.v1", + "pj.source_promotion.v1", + "pj.source_write.v1", + "pj.toolbox_object_read.v1", + "pj.toolbox_runtime.v1", + "pj.toolbox_write.v1", + "pj.viewport.v1", +} +# Versioned ids of PLUGIN-provided extensions (the host queries the plugin). +PLUGIN_EXTENSION_IDS = { + "pj.descriptor_import.v1", + "pj.parser_functional.v1", + "pj.parser_functional.v2", + "pj.parser_route_claims.v1", + "pj.topic_subscription.v1", +} + +VTABLE_TYPEDEF_RE = re.compile(r"typedef\s+struct\s+(\w+)?\s*\{") +FN_PTR_MEMBER_RE = re.compile(r"\(\s*\*\s*(?:const\s+)?(\w+)\s*\)\s*\(") +FN_PTR_TYPEDEF_RE = re.compile(r"typedef\s+[^;{]*?\(\s*\*\s*(\w+)\s*\)\s*\(") +TYPED_MEMBER_RE = re.compile(r"^\s*(?:const\s+)?(\w+)\s+(\w+)\s*$") SERVICE_ID_RE = re.compile(r'"(pj\.[a-z_0-9.]+\.v\d+)"') -FLAG_RE = re.compile(r"#define\s+(PJ_\w*_FLAG\w*)\s") +FLAG_RE = re.compile(r"#\s*define\s+(PJ_\w*_FLAG\w*)[\s(]") PROTOCOL_HEADER_GLOBS = [ ("pj_base/include/pj_base", "*.h"), @@ -52,37 +97,102 @@ def strip_comments(text: str) -> str: return re.sub(r"//[^\n]*", "", text) -def vtable_members(text: str) -> list[str]: - surfaces = [] - for match in HOST_VTABLE_RE.finditer(text): - struct_name = match.group(1) - # Body runs to the closing "} ;" — find the matching brace by depth. - depth = 0 +def _struct_bodies(text: str, path: Path) -> list[tuple[str, str]]: + """Every ``typedef struct [name] { body } name;`` as (name, body).""" + results: list[tuple[str, str]] = [] + for match in VTABLE_TYPEDEF_RE.finditer(text): start = text.index("{", match.start()) + depth = 0 for pos in range(start, len(text)): if text[pos] == "{": depth += 1 elif text[pos] == "}": depth -= 1 if depth == 0: - body = text[start:pos] break else: - raise SystemExit(f"unterminated struct {struct_name}") - surfaces.extend(f"{struct_name}::{m}" for m in MEMBER_RE.findall(body)) - return surfaces + raise SystemExit(f"unterminated struct in {path}") + body = text[start + 1 : pos] + name = match.group(1) + if name is None: + trailing = re.match(r"\s*(\w+)\s*;", text[pos + 1 :]) + if not trailing: + continue + name = trailing.group(1) + results.append((name, body)) + return results + + +def _top_level_members(body: str) -> list[str]: + """Member declarations split on ';' at parenthesis/brace depth zero.""" + members: list[str] = [] + depth = 0 + current: list[str] = [] + for char in body: + if char in "({[": + depth += 1 + elif char in ")}]": + depth -= 1 + if char == ";" and depth == 0: + members.append("".join(current).strip()) + current = [] + else: + current.append(char) + return [member for member in members if member] + + +def _callable_member_name(declaration: str, fn_ptr_typedefs: set[str]) -> str | None: + fn_ptr = FN_PTR_MEMBER_RE.search(declaration) + if fn_ptr: + return fn_ptr.group(1) + typed = TYPED_MEMBER_RE.match(declaration) + if typed and typed.group(1) in fn_ptr_typedefs: + return typed.group(2) + return None def extract(repo: Path) -> list[str]: surfaces: set[str] = set() + header_texts: list[tuple[Path, str]] = [] for rel, glob in PROTOCOL_HEADER_GLOBS: for header in sorted((repo / rel).glob(glob)): - text = strip_comments(header.read_text()) - surfaces.update(vtable_members(text)) - surfaces.update(FLAG_RE.findall(text)) + header_texts.append((header, strip_comments(header.read_text()))) + + fn_ptr_typedefs: set[str] = set() + for _, text in header_texts: + fn_ptr_typedefs.update(FN_PTR_TYPEDEF_RE.findall(text)) + + for path, text in header_texts: + surfaces.update(FLAG_RE.findall(text)) + for struct_name, body in _struct_bodies(text, path): + if not struct_name.endswith("_vtable_t"): + continue + is_host = struct_name in EXTRA_HOST_VTABLES or re.fullmatch( + r"PJ_\w*host_vtable_t", struct_name + ) + if not is_host: + if struct_name not in PLUGIN_SIDE_VTABLES: + raise SystemExit( + f"unclassified vtable {struct_name} in {path}: add it to " + "EXTRA_HOST_VTABLES or PLUGIN_SIDE_VTABLES in extract_host_surfaces.py" + ) + continue + for declaration in _top_level_members(body): + member = _callable_member_name(declaration, fn_ptr_typedefs) + if member is not None: + surfaces.add(f"{struct_name}::{member}") + for rel, glob in SERVICE_ID_GLOBS: for header in sorted((repo / rel).glob(glob)): - surfaces.update(SERVICE_ID_RE.findall(strip_comments(header.read_text()))) + for service_id in SERVICE_ID_RE.findall(strip_comments(header.read_text())): + if service_id in PLUGIN_EXTENSION_IDS: + continue + if service_id not in HOST_SERVICE_IDS: + raise SystemExit( + f"unclassified versioned id {service_id!r} in {header}: add it to " + "HOST_SERVICE_IDS or PLUGIN_EXTENSION_IDS in extract_host_surfaces.py" + ) + surfaces.add(service_id) return sorted(surfaces) diff --git a/tools/feature_floors/feature_floor_check.py b/tools/feature_floors/feature_floor_check.py new file mode 100644 index 00000000..feeb1da1 --- /dev/null +++ b/tools/feature_floors/feature_floor_check.py @@ -0,0 +1,534 @@ +#!/usr/bin/env python3 +# Copyright 2026 Davide Faconti +# SPDX-License-Identifier: Apache-2.0 +"""Reusable min_sdk_required floor checking over feature_floors.json. + +This module is the single owner of the table schema and of the rules a plugin +floor claim is validated against. It ships with the SDK package next to +feature_floors.json so any plugin author — in-tree or third-party — can run +the same check the official-plugins CI runs: + + python3 feature_floor_check.py --table feature_floors.json \ + --manifest my_plugin/manifest.json --sources my_plugin + +The contract: ``min_sdk_required`` (the functioning floor) must cover every +matched NON-negotiated surface — a hard surface above the floor is always a +failure. When any matched surface's introducing release exceeds the floor, the +manifest must declare ``suggested_sdk_version`` (the full-feature floor — +exactly max(since) over all matched surfaces) plus ``floor_test`` (one gtest, +existing in the plugin's tests, proving the plugin functions against a +floor-level host); both are forbidden when nothing exceeds the floor. Floors +below the table's ``minimum_supported_floor`` fail; over-declared floors are +the caller's business (this module reports the effective maximum so drivers +can warn). + +Scanning is comment- and raw-string-aware token matching with identifier +boundaries; the host-surface universe is a few dozen identifiers, so token +precision is sufficient. A Clang-based extractor is the documented upgrade +path if a real ambiguity ever appears. + +Standard library only. No repository-layout assumptions: callers hand in the +table path, source directories, and test directories. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Sequence + +SOURCE_SUFFIXES = { + ".c", + ".cc", + ".cpp", + ".cxx", + ".h", + ".hh", + ".hpp", + ".hxx", + ".inl", + ".ipp", + ".tpp", +} +VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") +RAW_STRING_START_RE = re.compile(r'(?:u8|u|U|L)?R"([^ ()\\\t\r\n]{0,16})\(') + + +class CheckError(Exception): + """A configuration or input shape error that must fail the check.""" + + +def parse_version(value: object, context: str) -> tuple[int, int, int]: + """Parse a plain X.Y.Z release version (the only form floors may name).""" + if not isinstance(value, str) or not VERSION_RE.fullmatch(value): + raise CheckError(f"{context} must name a stable release as X.Y.Z, got {value!r}") + parts = value.split(".") + return int(parts[0]), int(parts[1]), int(parts[2]) + + +def format_version(version: tuple[int, int, int]) -> str: + return ".".join(str(part) for part in version) + + +@dataclass(frozen=True) +class Surface: + identifier: str + since: tuple[int, int, int] + negotiated: bool + declaration: str + matcher: re.Pattern[str] + + @property + def since_text(self) -> str: + return format_version(self.since) + + +@dataclass(frozen=True) +class SurfaceTable: + surfaces: dict[str, Surface] + baseline: frozenset[str] + minimum_supported_floor: tuple[int, int, int] + path: Path + + @property + def minimum_text(self) -> str: + return format_version(self.minimum_supported_floor) + + +def _reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise CheckError(f"duplicate JSON key {key!r}") + result[key] = value + return result + + +def surface_matcher(identifier: str) -> re.Pattern[str]: + if not identifier: + raise CheckError("surface identifiers must not be empty") + pattern = re.escape(identifier) + if identifier[0].isalnum() or identifier[0] == "_": + pattern = r"(? SurfaceTable: + """Parse the compact version-grouped table (the only schema parser). + + Shape: ``{"schema_version": 1, "minimum_supported_floor": "X.Y.Z", + "since": {"X.Y.Z": {identifier: entry}}, "baseline": [identifier]}`` + where entry is a declaration string (``""`` = the declaration IS the + identifier, as with flag macros) or ``{"declaration": ..., + "negotiated": false}`` for a hard surface. ``negotiated`` defaults true. + """ + try: + raw = path.read_text(encoding="utf-8") + except OSError as exc: + raise CheckError(f"cannot read surface table {path}: {exc}") from exc + try: + document = json.loads(raw, object_pairs_hook=_reject_duplicate_json_keys) + except json.JSONDecodeError as exc: + raise CheckError(f"invalid JSON in surface table {path}: {exc}") from exc + + if not isinstance(document, dict): + raise CheckError(f"surface table {path} must contain a JSON object") + expected_keys = {"schema_version", "minimum_supported_floor", "_comment", "since", "baseline"} + unknown_keys = set(document) - expected_keys + if unknown_keys: + raise CheckError( + f"surface table {path} has unknown top-level keys: {', '.join(sorted(unknown_keys))}" + ) + if document.get("schema_version") != 1: + raise CheckError(f"surface table {path} must use schema_version 1") + + minimum = parse_version( + document.get("minimum_supported_floor"), f"minimum_supported_floor in {path}" + ) + + since_raw = document.get("since") + if not isinstance(since_raw, dict) or not since_raw: + raise CheckError(f"surface table {path} 'since' must be a non-empty object of version groups") + + surfaces: dict[str, Surface] = {} + for since_text, group in since_raw.items(): + since = parse_version(since_text, f"since group in {path}") + if since <= minimum: + raise CheckError( + f"since group {since_text} in {path} is at or below minimum_supported_floor — " + "those surfaces belong in 'baseline'" + ) + if not isinstance(group, dict) or not group: + raise CheckError(f"since group {since_text} in {path} must be a non-empty object") + for identifier, value in group.items(): + if not isinstance(identifier, str) or identifier != identifier.strip() or not identifier: + raise CheckError(f"surface identifier {identifier!r} must be a single trimmed token") + if identifier in surfaces: + raise CheckError( + f"surface {identifier!r} appears in more than one since group in {path}" + ) + negotiated = True + if isinstance(value, str): + declaration = value or identifier + elif isinstance(value, dict): + unknown = set(value) - {"declaration", "negotiated"} + if unknown: + raise CheckError( + f"surface {identifier!r} in {path} has unknown keys: {', '.join(sorted(unknown))}" + ) + declaration_raw = value.get("declaration", "") + if not isinstance(declaration_raw, str): + raise CheckError(f"surface {identifier!r} in {path} needs a string 'declaration'") + declaration = declaration_raw or identifier + negotiated = value.get("negotiated", True) + if not isinstance(negotiated, bool): + raise CheckError(f"surface {identifier!r} in {path} needs a boolean 'negotiated'") + else: + raise CheckError( + f"surface {identifier!r} in {path} must map to a declaration string or an object" + ) + surfaces[identifier] = Surface( + identifier, since, negotiated, declaration, surface_matcher(identifier) + ) + + # Baseline surfaces predate (or equal) the minimum supported floor, so they + # can never raise a floor at or above it — listed so verdicts can name them + # and so an exception naming one is "known" (and rejected as unnecessary + # via the stale-claim rule when nothing matches). + baseline_raw = document.get("baseline", []) + if not isinstance(baseline_raw, list): + raise CheckError(f"surface table {path} baseline must be an array of identifiers") + for identifier in baseline_raw: + if not isinstance(identifier, str) or identifier != identifier.strip() or not identifier: + raise CheckError(f"baseline identifier {identifier!r} must be a single trimmed token") + if identifier in surfaces: + raise CheckError( + f"surface {identifier!r} in {path} is listed twice (surfaces/baseline overlap " + "or duplicate baseline entry)" + ) + surfaces[identifier] = Surface( + identifier, minimum, True, identifier, surface_matcher(identifier) + ) + return SurfaceTable(surfaces, frozenset(baseline_raw), minimum, path) + + +# --- C/C++ source scanning -------------------------------------------------- + + +def _blank_except_newlines(text: str) -> str: + return "".join("\n" if char == "\n" else " " for char in text) + + +def strip_cpp_comments(text: str) -> str: + """Remove C/C++ comments while preserving offsets and string literals.""" + output: list[str] = [] + index = 0 + quote: str | None = None + escaped = False + while index < len(text): + if quote is not None: + char = text[index] + output.append(char) + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == quote: + quote = None + index += 1 + continue + + raw_match = RAW_STRING_START_RE.match(text, index) + if raw_match: + closing = ")" + raw_match.group(1) + '"' + end = text.find(closing, raw_match.end()) + end = len(text) if end < 0 else end + len(closing) + output.append(text[index:end]) + index = end + continue + if text.startswith("//", index): + end = text.find("\n", index) + end = len(text) if end < 0 else end + output.append(_blank_except_newlines(text[index:end])) + index = end + continue + if text.startswith("/*", index): + end = text.find("*/", index + 2) + end = len(text) if end < 0 else end + 2 + output.append(_blank_except_newlines(text[index:end])) + index = end + continue + char = text[index] + if char in {'"', "'"}: + quote = char + output.append(char) + index += 1 + return "".join(output) + + +def _splice_cpp_lines(text: str) -> tuple[str, list[int]]: + """Apply phase-2 backslash-newline removal, retaining an offset mapping.""" + output: list[str] = [] + original_offsets: list[int] = [] + index = 0 + while index < len(text): + if text.startswith("\\\r\n", index): + index += 3 + continue + if text.startswith("\\\n", index): + index += 2 + continue + output.append(text[index]) + original_offsets.append(index) + index += 1 + return "".join(output), original_offsets + + +def _original_line(text: str, original_offsets: Sequence[int], spliced_offset: int) -> int: + original_offset = ( + original_offsets[spliced_offset] if spliced_offset < len(original_offsets) else len(text) + ) + return text.count("\n", 0, original_offset) + 1 + + +def scan_source_file(path: Path, table: SurfaceTable) -> list[tuple[str, int]]: + """All (surface identifier, 1-based line) matches in one source file.""" + try: + raw_source = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + raise CheckError(f"cannot read C/C++ source {path}: {exc}") from exc + spliced_source, original_offsets = _splice_cpp_lines(raw_source) + searchable_source = strip_cpp_comments(spliced_source) + matches: list[tuple[str, int]] = [] + for surface in table.surfaces.values(): + # Fast native substring scan first: absence of the literal identifier + # proves the boundary-aware regex cannot match (measured ~55x). + if surface.identifier not in searchable_source: + continue + for match in surface.matcher.finditer(searchable_source): + matches.append( + (surface.identifier, _original_line(raw_source, original_offsets, match.start())) + ) + return matches + + +def iter_source_files(directory: Path, excluded_dirs: frozenset[str] = frozenset()) -> Iterable[Path]: + for path in sorted(directory.rglob("*")): + if not path.is_file() or path.suffix.lower() not in SOURCE_SUFFIXES: + continue + relative_parts = path.relative_to(directory).parts[:-1] + if any(part in excluded_dirs or part.startswith("build-") for part in relative_parts): + continue + yield path + + +# --- Exception + floor validation ------------------------------------------- + + +def validate_manifest_shape(manifest: dict) -> list[str]: + """Table-free shape rules for the floor-contract manifest fields. + + ``suggested_sdk_version`` is the FULL-FEATURE floor — the SDK release at + which every capability the plugin uses is available — while + ``min_sdk_required`` stays the functioning floor. ``floor_test`` names the + ONE gtest proving the plugin functions against a floor-level host + (degradations active). The two travel together: declaring a degraded range + without its proof, or a proof without the range, is malformed. Whether the + values are CORRECT against real surface usage is the table-aware check. + """ + errors: list[str] = [] + if manifest.get("sdk_floor_exceptions") is not None: + errors.append( + "sdk_floor_exceptions is no longer supported — declare " + "suggested_sdk_version + floor_test instead" + ) + suggested = manifest.get("suggested_sdk_version") + floor_test = manifest.get("floor_test") + if suggested is not None: + if not isinstance(suggested, str) or not VERSION_RE.fullmatch(suggested): + errors.append( + f"suggested_sdk_version must name a stable release as X.Y.Z, got {suggested!r}" + ) + else: + minimum = manifest.get("min_sdk_required") + if isinstance(minimum, str) and VERSION_RE.fullmatch(minimum): + if tuple(map(int, suggested.split("."))) < tuple(map(int, minimum.split("."))): + errors.append( + f"suggested_sdk_version {suggested} is below min_sdk_required {minimum}" + ) + if floor_test is None: + errors.append( + "floor_test is required alongside suggested_sdk_version: name the gtest " + "proving the plugin functions against a floor-level host" + ) + if floor_test is not None: + if not isinstance(floor_test, str) or "." not in floor_test: + errors.append("floor_test must name a gtest as 'Suite.Name'") + if suggested is None: + errors.append("floor_test is only meaningful alongside suggested_sdk_version — remove it") + return errors + + +def test_exists(test_dirs: Iterable[Path], test_name: str) -> bool: + """True when `Suite.Name` is a real gtest TEST/TEST_F/TEST_P declaration. + + Sources are comment-stripped first: a commented-out test must not satisfy + the floor_test obligation. + """ + if "." not in test_name: + return False + suite, _, name = test_name.partition(".") + pattern = re.compile( + r"TEST(?:_F|_P)?\s*\(\s*" + re.escape(suite) + r"\s*,\s*" + re.escape(name) + r"\s*[,)]" + ) + for directory in test_dirs: + if not directory.is_dir(): + continue + for path in iter_source_files(directory): + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + if pattern.search(strip_cpp_comments(text)): + return True + return False + + +def floor_problems( + manifest: dict, + declared: tuple[int, int, int], + table: SurfaceTable, + matched: dict[str, str], + test_dirs: Iterable[Path], +) -> list[str]: + """The whole floor contract, table-aware. + + 1. ``min_sdk_required`` >= since of every matched NON-negotiated surface — + a hard surface above the floor is always a failure, nothing waives it. + 2. ``suggested_sdk_version`` is REQUIRED exactly when a matched surface's + since exceeds the floor, and must equal max(since) over ALL matched + surfaces; it is forbidden otherwise (it would repeat the floor). + 3. ``floor_test`` (required with suggested) must name a gtest that exists + in the plugin's tests — the proof the degraded configuration works. + """ + problems = validate_manifest_shape(manifest) + if problems: + return problems + if declared < table.minimum_supported_floor: + problems.append( + f"min_sdk_required {format_version(declared)} is below the supported floor " + f"{table.minimum_text} (older history is not classified)" + ) + + above_floor = sorted( + (table.surfaces[identifier] for identifier in matched if table.surfaces[identifier].since > declared), + key=lambda surface: (surface.since, surface.identifier), + ) + for surface in above_floor: + if not surface.negotiated: + problems.append( + f"{surface.identifier} (since {surface.since_text}) exceeds floor " + f"{format_version(declared)} at {matched[surface.identifier]} and is not " + "runtime-negotiated — raise min_sdk_required" + ) + + suggested = manifest.get("suggested_sdk_version") + if above_floor: + expected = format_version( + max(table.surfaces[identifier].since for identifier in matched) + ) + if suggested is None: + newest = above_floor[-1] + problems.append( + f"suggested_sdk_version is missing: {newest.identifier} " + f"(since {newest.since_text}, at {matched[newest.identifier]}) exceeds floor " + f"{format_version(declared)} — declare suggested_sdk_version {expected} " + "(+ floor_test) or raise min_sdk_required" + ) + elif suggested != expected: + problems.append( + f"suggested_sdk_version is {suggested} but the full-feature floor over the " + f"matched surfaces is {expected} — declare exactly that" + ) + else: + floor_test = manifest["floor_test"] + if not test_exists(test_dirs, floor_test): + problems.append( + f"floor_test {floor_test!r} not found in the plugin's tests" + ) + elif suggested is not None: + problems.append( + "suggested_sdk_version is only meaningful when a used surface exceeds " + "min_sdk_required (it would just repeat the floor) — remove it" + ) + return problems + + +def effective_maximum(table: SurfaceTable, matched: dict[str, str]) -> tuple[int, int, int] | None: + """The highest introducing release among matched surfaces (for over-declared warnings).""" + return max((table.surfaces[identifier].since for identifier in matched), default=None) + + +# --- Standalone CLI for out-of-tree plugin authors --------------------------- + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Check one plugin's min_sdk_required against the host surfaces its sources use." + ) + parser.add_argument("--table", type=Path, required=True, help="Path to feature_floors.json") + parser.add_argument("--manifest", type=Path, required=True, help="Path to the plugin's manifest.json") + parser.add_argument( + "--sources", + type=Path, + action="append", + required=True, + help="Source directory to scan (repeatable)", + ) + parser.add_argument( + "--tests", + type=Path, + action="append", + help="Test directory for fallback-test lookup (default: /tests and /test)", + ) + args = parser.parse_args(argv) + + try: + table = load_surface_table(args.table) + manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + if not isinstance(manifest, dict): + raise CheckError(f"{args.manifest} must contain a JSON object") + declared = parse_version(manifest.get("min_sdk_required"), "min_sdk_required") + + matched: dict[str, str] = {} + for directory in args.sources: + for path in iter_source_files(directory, frozenset({"build", "test", "tests"})): + for identifier, line in scan_source_file(path, table): + matched.setdefault(identifier, f"{path}:{line}") + + test_dirs = args.tests or [args.manifest.parent / "tests", args.manifest.parent / "test"] + problems = floor_problems(manifest, declared, table, matched, test_dirs) + except CheckError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + if problems: + print(f"FAIL (floor {format_version(declared)}):", file=sys.stderr) + for problem in problems: + print(f" - {problem}", file=sys.stderr) + return 1 + summary = ", ".join(sorted(matched)) if matched else "no host surfaces matched" + suggested = manifest.get("suggested_sdk_version") + degraded_note = f", full features at {suggested}" if suggested else "" + print(f"PASS (floor {format_version(declared)}{degraded_note}): {summary}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/feature_floors/host_surfaces.snapshot b/tools/feature_floors/host_surfaces.snapshot index 312a3ba7..8ed8d06d 100644 --- a/tools/feature_floors/host_surfaces.snapshot +++ b/tools/feature_floors/host_surfaces.snapshot @@ -7,6 +7,8 @@ PJ_INGEST_COMPLETION_FLAG_NONE PJ_MODULE_PARSE_INPUT_FLAG_HAS_TIMESTAMP PJ_PARSER_ROUTE_FLAG_OBJECT_V1 PJ_PARSER_ROUTE_FLAG_SCALAR_V1 +PJ_colormap_registry_vtable_t::register_map +PJ_colormap_registry_vtable_t::unregister_map 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 @@ -36,12 +38,10 @@ PJ_object_read_host_vtable_t::read_latest_at PJ_object_read_host_vtable_t::release_bytes PJ_object_read_host_vtable_t::time_range PJ_object_read_host_vtable_t::topic_metadata -PJ_object_write_host_vtable_t::fetch_ctx_destroy PJ_object_write_host_vtable_t::push_lazy PJ_object_write_host_vtable_t::push_owned PJ_object_write_host_vtable_t::register_topic PJ_object_write_host_vtable_t::set_retention_budget -PJ_parser_object_write_host_vtable_t::fetch_ctx_destroy PJ_parser_object_write_host_vtable_t::push_lazy PJ_parser_object_write_host_vtable_t::push_owned PJ_parser_runtime_host_vtable_t::report_diagnostic @@ -64,6 +64,12 @@ PJ_plot_tab_host_vtable_t::list_tab_ids PJ_plot_tab_host_vtable_t::remove_curve PJ_plot_tab_host_vtable_t::tab_config PJ_service_registry_vtable_t::get_service +PJ_settings_store_vtable_t::contains +PJ_settings_store_vtable_t::get_string +PJ_settings_store_vtable_t::get_string_list +PJ_settings_store_vtable_t::remove_key +PJ_settings_store_vtable_t::set_string +PJ_settings_store_vtable_t::set_string_list PJ_source_promotion_host_vtable_t::promote_to_file_source PJ_source_write_host_vtable_t::append_arrow_stream PJ_source_write_host_vtable_t::append_bound_record @@ -91,11 +97,7 @@ PJ_viewport_host_vtable_t::zoom_reset PJ_viewport_host_vtable_t::zoom_to_time_range pj.colormap.v1 pj.data_processors.v1 -pj.descriptor_import.v1 -pj.parser_functional.v1 -pj.parser_functional.v2 pj.parser_object_write.v1 -pj.parser_route_claims.v1 pj.parser_runtime.v1 pj.parser_write.v1 pj.playback.v1 @@ -108,5 +110,4 @@ pj.source_write.v1 pj.toolbox_object_read.v1 pj.toolbox_runtime.v1 pj.toolbox_write.v1 -pj.topic_subscription.v1 pj.viewport.v1 From 52d17ee17f7218d901ba8e08ee2532fa9a6efae6 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Mon, 7 Sep 2026 00:02:52 +0200 Subject: [PATCH 5/5] fix(floors): harden the checker core per correctness review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match vtable surfaces by both the wrapper identifier and the ABI member token (direct C-ABI spellings count as usage; above-floor surfaces only — baseline member names would match ordinary C++). Blank string and char literal contents like comments so prose naming a surface is not usage. floor_test lookup uses the same preprocessing as scanning, so a spliced-comment or raw-string TEST() cannot satisfy it. Known limitations (token pasting, runtime-surface scope) documented. Drop two accidentally tracked .pyc files and ignore __pycache__. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTJvEsHX26rG4cHmqppvQg --- .claude/skills/plotjuggler-plugin/SKILL.md | 14 ++-- .gitignore | 1 + .../extract_host_surfaces.cpython-314.pyc | Bin 11150 -> 0 bytes .../feature_floor_check.cpython-314.pyc | Bin 30931 -> 0 bytes tools/feature_floors/feature_floor_check.py | 66 +++++++++++++----- 5 files changed, 58 insertions(+), 23 deletions(-) delete mode 100644 tools/feature_floors/__pycache__/extract_host_surfaces.cpython-314.pyc delete mode 100644 tools/feature_floors/__pycache__/feature_floor_check.cpython-314.pyc diff --git a/.claude/skills/plotjuggler-plugin/SKILL.md b/.claude/skills/plotjuggler-plugin/SKILL.md index 231a33d6..798f525d 100644 --- a/.claude/skills/plotjuggler-plugin/SKILL.md +++ b/.claude/skills/plotjuggler-plugin/SKILL.md @@ -183,7 +183,10 @@ When you adopt a surface newer than your floor, pick one: - **Raise `min_sdk_required`** to that surface's release — mandatory for a non-negotiated (hard protocol) surface, and right whenever the feature is - essential to what your plugin is for. + essential to what your plugin is for. The reverse is NOT symmetric: the + checker sees only runtime host surfaces, so a floor above anything it + matched may still be correct (compile-time SDK APIs are outside the check) + — never lower a floor just because the checker matched nothing. - **Declare the degraded range** — when the surface is runtime-negotiated and the wrapper's error return is acceptable (ignore it and carry on). Two manifest fields, required together and forbidden when nothing exceeds the @@ -192,15 +195,18 @@ When you adopt a surface newer than your floor, pick one: ```json "min_sdk_required": "0.28.0", "suggested_sdk_version": "0.32.0", -"floor_test": "McapDatasetMetadata.ImportSucceedsAgainstFloorLevelHost" +"floor_test": "McapDatasetMetadata.MetadataPublishDegradesCleanlyOnFloorLevelHost" ``` `suggested_sdk_version` is the full-feature floor (exactly the max introducing release over every host surface you use — the checker validates it), so hosts can tell users which PlotJuggler unlocks your whole feature set. `floor_test` -names one gtest, existing in your `tests/`, proving the plugin functions +names one gtest, existing in your `tests/`, proving the DEGRADED PATH works against a floor-level host — drive it with a `struct_size`-truncated host -vtable (the testing fixtures support this). Neither field is ever a gate: +vtable and poison the slots past the truncation so a missing guard fails the +test. Helper-level granularity is acceptable when the full entry point needs a +live host; the test comment must then state the routing fact (which entry +point routes through the helper). Neither field is ever a gate: `min_sdk_required` remains the only admission criterion, and `min_plotjuggler_version` is deprecated. diff --git a/.gitignore b/.gitignore index 96461698..ebb48291 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ pj_media/testdata/ # git worktrees live here by policy (see the global CLAUDE.md) .worktrees/ +__pycache__/ diff --git a/tools/feature_floors/__pycache__/extract_host_surfaces.cpython-314.pyc b/tools/feature_floors/__pycache__/extract_host_surfaces.cpython-314.pyc deleted file mode 100644 index a5dd207dedd7e00b12d10fd5699e56f209245b9b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11150 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 C6#|?9 diff --git a/tools/feature_floors/__pycache__/feature_floor_check.cpython-314.pyc b/tools/feature_floors/__pycache__/feature_floor_check.cpython-314.pyc deleted file mode 100644 index 470a3989f6478c4f10174c137a76f1cbfb242282..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30931 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 diff --git a/tools/feature_floors/feature_floor_check.py b/tools/feature_floors/feature_floor_check.py index feeb1da1..ca04990d 100644 --- a/tools/feature_floors/feature_floor_check.py +++ b/tools/feature_floors/feature_floor_check.py @@ -22,10 +22,13 @@ the caller's business (this module reports the effective maximum so drivers can warn). -Scanning is comment- and raw-string-aware token matching with identifier -boundaries; the host-surface universe is a few dozen identifiers, so token -precision is sufficient. A Clang-based extractor is the documented upgrade -path if a real ambiguity ever appears. +Scanning is token matching with identifier boundaries over comment- and +string-literal-stripped sources; the host-surface universe is a few dozen +identifiers, so token precision is sufficient. Known limitation: an identifier +assembled by token-pasting macros (``a ## b``) is invisible — a Clang-based +extractor is the documented upgrade path if a real ambiguity ever appears. +Scope boundary: only RUNTIME host-contract surfaces are visible; compile-time +SDK library APIs never constrain (or justify lowering) a floor. Standard library only. No repository-layout assumptions: callers hand in the table path, source directories, and test directories. @@ -80,6 +83,10 @@ class Surface: since: tuple[int, int, int] negotiated: bool declaration: str + # Every spelling that counts as usage: the C++ wrapper identifier plus, + # for vtable surfaces, the raw ABI member token (host.vtable->member is a + # supported call spelling and must not evade the floor). + tokens: tuple[str, ...] matcher: re.Pattern[str] @property @@ -108,15 +115,26 @@ def _reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, ob return result -def surface_matcher(identifier: str) -> re.Pattern[str]: - if not identifier: - raise CheckError("surface identifiers must not be empty") - pattern = re.escape(identifier) - if identifier[0].isalnum() or identifier[0] == "_": +def _token_pattern(token: str) -> str: + pattern = re.escape(token) + if token[0].isalnum() or token[0] == "_": pattern = r"(? re.Pattern[str]: + if not tokens or not all(tokens): + raise CheckError("surface identifiers must not be empty") + return re.compile("|".join(_token_pattern(token) for token in tokens)) + + +def _surface_tokens(identifier: str, declaration: str) -> tuple[str, ...]: + member = declaration.rsplit("::", 1)[1] if "::" in declaration else "" + if member and member != identifier: + return (identifier, member) + return (identifier,) def load_surface_table(path: Path) -> SurfaceTable: @@ -193,8 +211,9 @@ def load_surface_table(path: Path) -> SurfaceTable: raise CheckError( f"surface {identifier!r} in {path} must map to a declaration string or an object" ) + tokens = _surface_tokens(identifier, declaration) surfaces[identifier] = Surface( - identifier, since, negotiated, declaration, surface_matcher(identifier) + identifier, since, negotiated, declaration, tokens, surface_matcher(tokens) ) # Baseline surfaces predate (or equal) the minimum supported floor, so they @@ -213,7 +232,7 @@ def load_surface_table(path: Path) -> SurfaceTable: "or duplicate baseline entry)" ) surfaces[identifier] = Surface( - identifier, minimum, True, identifier, surface_matcher(identifier) + identifier, minimum, True, identifier, (identifier,), surface_matcher((identifier,)) ) return SurfaceTable(surfaces, frozenset(baseline_raw), minimum, path) @@ -226,7 +245,11 @@ def _blank_except_newlines(text: str) -> str: def strip_cpp_comments(text: str) -> str: - """Remove C/C++ comments while preserving offsets and string literals.""" + """Blank C/C++ comments AND string/char-literal contents, preserving offsets. + + Literal contents must not count as identifier usage (a help string naming a + surface is not a call), so they are blanked exactly like comments. + """ output: list[str] = [] index = 0 quote: str | None = None @@ -234,13 +257,17 @@ def strip_cpp_comments(text: str) -> str: while index < len(text): if quote is not None: char = text[index] - output.append(char) if escaped: escaped = False + output.append(" " if char != "\n" else "\n") elif char == "\\": escaped = True + output.append(" ") elif char == quote: quote = None + output.append(char) + else: + output.append(" " if char != "\n" else "\n") index += 1 continue @@ -249,7 +276,7 @@ def strip_cpp_comments(text: str) -> str: closing = ")" + raw_match.group(1) + '"' end = text.find(closing, raw_match.end()) end = len(text) if end < 0 else end + len(closing) - output.append(text[index:end]) + output.append(_blank_except_newlines(text[index:end])) index = end continue if text.startswith("//", index): @@ -307,9 +334,9 @@ def scan_source_file(path: Path, table: SurfaceTable) -> list[tuple[str, int]]: searchable_source = strip_cpp_comments(spliced_source) matches: list[tuple[str, int]] = [] for surface in table.surfaces.values(): - # Fast native substring scan first: absence of the literal identifier + # Fast native substring scan first: absence of every matchable token # proves the boundary-aware regex cannot match (measured ~55x). - if surface.identifier not in searchable_source: + if not any(token in searchable_source for token in surface.tokens): continue for match in surface.matcher.finditer(searchable_source): matches.append( @@ -395,7 +422,8 @@ def test_exists(test_dirs: Iterable[Path], test_name: str) -> bool: text = path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): continue - if pattern.search(strip_cpp_comments(text)): + spliced, _ = _splice_cpp_lines(text) + if pattern.search(strip_cpp_comments(spliced)): return True return False