diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44f9b13..63ba7a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,5 +31,29 @@ jobs: node-version: 22 cache: pnpm + - id: example-versions + name: Read fixed example dependencies + run: | + node --input-type=module <<'JS' + import {readFileSync, appendFileSync} from 'node:fs'; + const {components} = JSON.parse(readFileSync('data/reference/versions.json')); + appendFileSync(process.env.GITHUB_OUTPUT, `cpp_ref=${components['netft-cpp'].sourceCommit}\npynetft_version=${components.pyNetFT.version}\n`); + JS + + - name: Obtain the fixed C++ SDK for examples + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: netft/netft-cpp + ref: ${{ steps.example-versions.outputs.cpp_ref }} + path: .dependencies/netft-cpp + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + + - name: Install the fixed Python package for examples + run: python3 -m pip install 'pynetft==${{ steps.example-versions.outputs.pynetft_version }}' + - run: pnpm install --frozen-lockfile - run: pnpm check diff --git a/.gitignore b/.gitignore index 5afa2e1..f33fe79 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ Thumbs.db # Local development plans .superpowers/ + +.dependencies/ diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index bdea94a..021f970 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -22,6 +22,7 @@ "**/*.mdx", "!node_modules/**", "!build/**", + "!.dependencies/**", "!.superpowers/**", ], } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b6d7d5..114b09b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,3 +30,13 @@ Tests should cover data contracts, validators, generated routes, links, and othe Keep each pull request focused. Explain the reader-facing outcome, source repositories checked, validation run, and any accessibility or deployment impact. By contributing authored content under `docs/`, you agree to license that contribution under [Creative Commons Attribution 4.0 International](LICENSE-DOCS). Contributions to code, examples, tests, configuration, scripts, and site tooling are licensed under the [Apache License 2.0](LICENSE). Do not submit third-party material unless its terms permit inclusion and its original license is preserved. + +## Example dependencies + +Example checks are required by default: `NETFT_CPP_ROOT` must point to SDK headers (the default is `.dependencies/netft-cpp`), and `PYTHON` must import the version of pynetft in `data/reference/versions.json`. CI obtains the fixed SDK commit and Python release explicitly. `pnpm check:examples --allow-missing` is a local opt-in for working on prose without installed dependencies; skipped checks are not passes. Imports load the Python example without invoking `main()` or contacting hardware. + +## Reference provenance + +`pnpm update:references -- --cpp PATH --python PATH --cli PATH --ros PATH` requires clean checkouts whose origin, HEAD, exact tag and project version match `data/reference/versions.json`. CLI extraction consumes `--schema` and also verifies executable version, commit and clean-source identity; point `NETFT_CLI_EXECUTABLE` at the matching build. Older CLI releases without this interface cannot regenerate their CLI manifest with the new extractor. Their existing published reference remains pinned to its real release commit. + +To review an unreleased candidate, copy the versions file to a temporary file, set each entry's `publication` and `sourceTag` to `unreleased`, set `sourceCommit` to its exact clean checkout HEAD, and use a `sourceUrl` ending in `/tree/`. Set `NETFT_REFERENCE_VERSIONS` and `NETFT_REFERENCE_OUTPUT` to the temporary metadata file and an existing temporary directory. Review generated candidates before explicitly replacing published references; do not label candidate code as a released tag. diff --git a/data/ecosystem.json b/data/ecosystem.json index 078bf8d..e697526 100644 --- a/data/ecosystem.json +++ b/data/ecosystem.json @@ -12,11 +12,12 @@ "audience": "Operators and automation", "docsPath": "tutorials/cli/inspect-and-validate", "repository": "https://github.com/netft/netft-cli", - "stableVersion": "0.2.1", + "version": "0.3.0", "platforms": ["Linux", "macOS", "Windows"], "installMethods": ["release", "source"], "documentationOwner": "netft-docs", - "priority": 1 + "priority": 1, + "releaseChannel": "stable" }, { "id": "netft-viewer", @@ -25,11 +26,12 @@ "audience": "Lab and field users", "docsPath": "tutorials/viewer/connect-and-inspect", "repository": "https://github.com/netft/netft-viewer", - "stableVersion": "0.1.1", + "version": "0.2.1", "platforms": ["Linux", "macOS", "Windows"], "installMethods": ["release", "source"], "documentationOwner": "netft-docs", - "priority": 2 + "priority": 2, + "releaseChannel": "development" } ] }, @@ -45,11 +47,12 @@ "audience": "C++ developers", "docsPath": "tutorials/sdks/cpp", "repository": "https://github.com/netft/netft-cpp", - "stableVersion": "0.3.3", + "version": "0.3.4", "platforms": ["Linux", "macOS", "Windows"], "installMethods": ["source"], "documentationOwner": "netft-docs", - "priority": 1 + "priority": 1, + "releaseChannel": "stable" }, { "id": "pynetft", @@ -58,11 +61,12 @@ "audience": "Python developers", "docsPath": "tutorials/sdks/python", "repository": "https://github.com/netft/pyNetFT", - "stableVersion": "2.1.1", + "version": "2.1.2", "platforms": ["Linux", "macOS", "Windows"], "installMethods": ["PyPI", "source"], "documentationOwner": "netft-docs", - "priority": 2 + "priority": 2, + "releaseChannel": "stable" } ] }, @@ -78,11 +82,12 @@ "audience": "Robotics developers", "docsPath": "tutorials/ros/standalone", "repository": "https://github.com/netft/ros-netft", - "stableVersion": "0.3.3", + "version": "0.4.0", "platforms": ["Linux"], "installMethods": ["ROS", "source"], "documentationOwner": "netft-docs", - "priority": 1 + "priority": 1, + "releaseChannel": "stable" } ] } diff --git a/data/reference/cli.json b/data/reference/cli.json index 221d577..1af0a66 100644 --- a/data/reference/cli.json +++ b/data/reference/cli.json @@ -2,117 +2,131 @@ "schemaVersion": 1, "kind": "cli", "component": "netft-cli", - "version": "0.2.1", - "sourceTag": "v0.2.1", - "sourceUrl": "https://github.com/netft/netft-cli/tree/v0.2.1", + "version": "0.3.0", + "sourceCommit": "ed946edcf31d4a6fab06dadaa3826afc7522f161", "options": [ - { - "id": "color", - "longName": "color", - "valueType": "auto|always|never", - "values": ["auto", "always", "never"], - "description": "Control color in human-readable output." - }, - { - "id": "count", - "longName": "count", - "valueType": "INTEGER", - "values": [], - "description": "Stop after recording this many samples." - }, - { - "id": "duration", - "longName": "duration", - "valueType": "DURATION", - "values": [], - "description": "Stop after a bounded duration." - }, { "id": "format", "longName": "format", - "valueType": "auto|text|json|table|ndjson|csv", - "values": ["auto", "text", "json", "table", "ndjson", "csv"], - "description": "Select the output format." + "valueType": "choice", + "description": "Select the output format.", + "repeatable": false, + "values": ["auto", "text", "json", "table", "ndjson", "csv"] + }, + { + "id": "output", + "longName": "output", + "valueType": "path", + "description": "Write output to a file.", + "repeatable": false, + "values": [] }, { "id": "http-port", "longName": "http-port", - "valueType": "PORT", - "values": [], - "description": "Set the sensor HTTP port." + "valueType": "port", + "description": "Set the sensor HTTP port.", + "repeatable": false, + "values": [] }, { - "id": "max-loss", - "longName": "max-loss", - "valueType": "NUMBER", - "values": [], - "description": "Allow at most this packet loss percentage." + "id": "rdt-port", + "longName": "rdt-port", + "valueType": "port", + "description": "Set the sensor RDT port.", + "repeatable": false, + "values": [] }, { - "id": "max-reconnects", - "longName": "max-reconnects", - "valueType": "INTEGER", - "values": [], - "description": "Allow at most this many reconnects." + "id": "timeout", + "longName": "timeout", + "valueType": "duration", + "description": "Set the connection timeout.", + "repeatable": false, + "values": [] }, { - "id": "min-rate", - "longName": "min-rate", - "valueType": "NUMBER", - "values": [], - "description": "Require a minimum observed receive rate." + "id": "rate", + "longName": "rate", + "valueType": "positive-number", + "description": "Set the monitor output rate in hertz.", + "repeatable": false, + "values": [] }, { - "id": "output", - "longName": "output", - "valueType": "PATH", - "values": [], - "description": "Write output to a file." + "id": "duration", + "longName": "duration", + "valueType": "duration", + "description": "Stop after a bounded duration.", + "repeatable": false, + "values": [] }, { - "id": "quiet", - "longName": "quiet", - "shortName": "q", + "id": "yes", + "longName": "yes", + "shortName": "y", "valueType": "flag", - "values": [], - "description": "Suppress progress output." + "description": "Confirm the operation without prompting.", + "repeatable": false, + "values": [] }, { - "id": "rate", - "longName": "rate", - "valueType": "NUMBER", - "values": [], - "description": "Set the monitor output rate in hertz." + "id": "min-rate", + "longName": "min-rate", + "valueType": "positive-number", + "description": "Require a minimum observed receive rate.", + "repeatable": false, + "values": [] }, { - "id": "rdt-port", - "longName": "rdt-port", - "valueType": "PORT", - "values": [], - "description": "Set the sensor RDT port." + "id": "max-loss", + "longName": "max-loss", + "valueType": "nonnegative-number", + "description": "Allow at most this packet loss percentage.", + "repeatable": false, + "values": [] }, { - "id": "timeout", - "longName": "timeout", - "valueType": "DURATION", - "values": [], - "description": "Set the connection timeout." + "id": "max-reconnects", + "longName": "max-reconnects", + "valueType": "nonnegative-integer", + "description": "Allow at most this many reconnects.", + "repeatable": false, + "values": [] + }, + { + "id": "count", + "longName": "count", + "valueType": "positive-integer", + "description": "Stop after recording this many samples.", + "repeatable": false, + "values": [] }, { "id": "verbose", "longName": "verbose", "shortName": "v", "valueType": "flag", - "values": [], - "description": "Show additional diagnostic progress." + "description": "Show additional diagnostic progress.", + "repeatable": false, + "values": [] }, { - "id": "yes", - "longName": "yes", - "shortName": "y", + "id": "quiet", + "longName": "quiet", + "shortName": "q", "valueType": "flag", - "values": [], - "description": "Confirm the operation without prompting." + "description": "Suppress progress output.", + "repeatable": false, + "values": [] + }, + { + "id": "color", + "longName": "color", + "valueType": "choice", + "description": "Control color in human-readable output.", + "repeatable": false, + "values": ["auto", "always", "never"] } ], "commands": [ @@ -131,7 +145,14 @@ "format", "output" ], - "positionals": [], + "positionals": [ + { + "name": "HOST", + "valueType": "host", + "required": false, + "values": [] + } + ], "examples": [ "netft info sensor.example", "netft info sensor.example --format json" @@ -139,10 +160,10 @@ "exitStatuses": [0, 2, 3, 6] }, { - "id": "check", - "name": "check", - "synopsis": "netft check [HOST] [OPTIONS]", - "description": "Run a bounded sensor stream health check.", + "id": "monitor", + "name": "monitor", + "synopsis": "netft monitor [HOST] [OPTIONS]", + "description": "Display the latest sensor sample at a bounded output rate.", "optionIds": [ "verbose", "quiet", @@ -152,23 +173,28 @@ "timeout", "format", "output", - "duration", - "min-rate", - "max-loss", - "max-reconnects" + "rate", + "duration" + ], + "positionals": [ + { + "name": "HOST", + "valueType": "host", + "required": false, + "values": [] + } ], - "positionals": [], "examples": [ - "netft check sensor.example", - "netft check sensor.example --format json" + "netft monitor sensor.example", + "netft monitor sensor.example --rate 50" ], - "exitStatuses": [0, 2, 3, 4, 5, 6, 7, 130] + "exitStatuses": [0, 2, 3, 4, 5, 6, 130] }, { - "id": "monitor", - "name": "monitor", - "synopsis": "netft monitor [HOST] [OPTIONS]", - "description": "Display the latest sensor sample at a bounded output rate.", + "id": "check", + "name": "check", + "synopsis": "netft check [HOST] [OPTIONS]", + "description": "Run a bounded sensor stream health check.", "optionIds": [ "verbose", "quiet", @@ -178,15 +204,24 @@ "timeout", "format", "output", - "rate", - "duration" + "duration", + "min-rate", + "max-loss", + "max-reconnects" + ], + "positionals": [ + { + "name": "HOST", + "valueType": "host", + "required": false, + "values": [] + } ], - "positionals": [], "examples": [ - "netft monitor sensor.example", - "netft monitor sensor.example --rate 50" + "netft check sensor.example", + "netft check sensor.example --format json" ], - "exitStatuses": [0, 2, 3, 4, 5, 6, 130] + "exitStatuses": [0, 2, 3, 4, 5, 6, 7, 130] }, { "id": "record", @@ -205,7 +240,14 @@ "duration", "count" ], - "positionals": [], + "positionals": [ + { + "name": "HOST", + "valueType": "host", + "required": false, + "values": [] + } + ], "examples": [ "netft record sensor.example --output measurement.csv", "netft record sensor.example --output measurement.ndjson --duration 60s" @@ -228,7 +270,14 @@ "output", "yes" ], - "positionals": [], + "positionals": [ + { + "name": "HOST", + "valueType": "host", + "required": false, + "values": [] + } + ], "examples": [ "netft bias sensor.example", "netft bias sensor.example --yes" @@ -241,7 +290,14 @@ "synopsis": "netft completion SHELL", "description": "Generate a shell completion script.", "optionIds": ["verbose", "quiet", "color"], - "positionals": [], + "positionals": [ + { + "name": "SHELL", + "valueType": "choice", + "required": true, + "values": ["bash", "zsh", "fish", "powershell"] + } + ], "examples": ["netft completion bash", "netft completion powershell"], "exitStatuses": [0, 2] } @@ -283,5 +339,8 @@ "code": 130, "meaning": "Interrupted" } - ] + ], + "publication": "released", + "sourceTag": "v0.3.0", + "sourceUrl": "https://github.com/netft/netft-cli/tree/v0.3.0" } diff --git a/data/reference/cpp.json b/data/reference/cpp.json index a2e32c2..32ca150 100644 --- a/data/reference/cpp.json +++ b/data/reference/cpp.json @@ -2,9 +2,11 @@ "schemaVersion": 1, "kind": "cpp-api", "component": "netft-cpp", - "version": "0.3.3", - "sourceTag": "v0.3.3", - "sourceUrl": "https://github.com/netft/netft-cpp/tree/v0.3.3", + "version": "0.3.4", + "sourceCommit": "1ce68a08a9387d9a36d22c3c259e44533199c347", + "publication": "released", + "sourceTag": "v0.3.4", + "sourceUrl": "https://github.com/netft/netft-cpp/tree/v0.3.4", "symbols": [ { "id": "netft.Calibration", diff --git a/data/reference/python.json b/data/reference/python.json index 2b990b9..db50721 100644 --- a/data/reference/python.json +++ b/data/reference/python.json @@ -2,9 +2,11 @@ "schemaVersion": 1, "kind": "python-api", "component": "pyNetFT", - "version": "2.1.1", - "sourceTag": "v2.1.1", - "sourceUrl": "https://github.com/netft/pyNetFT/tree/v2.1.1", + "version": "2.1.2", + "sourceCommit": "7d7b9fb2bfb53e2cfc362c3d76d5a0222d385464", + "publication": "released", + "sourceTag": "v2.1.2", + "sourceUrl": "https://github.com/netft/pyNetFT/tree/v2.1.2", "symbols": [ { "id": "pynetft.Calibration", diff --git a/data/reference/ros.json b/data/reference/ros.json index a603118..e562161 100644 --- a/data/reference/ros.json +++ b/data/reference/ros.json @@ -2,9 +2,11 @@ "schemaVersion": 1, "kind": "ros", "component": "ros-netft", - "version": "0.3.3", - "sourceTag": "0.3.3", - "sourceUrl": "https://github.com/netft/ros-netft/tree/0.3.3", + "version": "0.4.0", + "sourceCommit": "e272550080e10a1f1bc71c2ce6499c556ecd538c", + "publication": "released", + "sourceTag": "0.4.0", + "sourceUrl": "https://github.com/netft/ros-netft/tree/0.4.0", "pluginClass": "netft_driver/NetFTHardwareInterface", "standaloneParameters": [ { @@ -183,6 +185,12 @@ "default": 2, "description": "Maximum wait for the first healthy hardware sample" }, + { + "name": "allow_bias", + "type": "bool", + "default": true, + "description": "allow bias" + }, { "name": "bias_service", "type": "string", diff --git a/data/reference/versions.json b/data/reference/versions.json index c969758..aed01bb 100644 --- a/data/reference/versions.json +++ b/data/reference/versions.json @@ -2,34 +2,40 @@ "schemaVersion": 1, "components": { "netft-cpp": { - "version": "0.3.3", - "sourceTag": "v0.3.3", + "version": "0.3.4", + "sourceTag": "v0.3.4", "repository": "https://github.com/netft/netft-cpp", - "sourceUrl": "https://github.com/netft/netft-cpp/tree/v0.3.3" + "sourceUrl": "https://github.com/netft/netft-cpp/tree/v0.3.4", + "sourceCommit": "1ce68a08a9387d9a36d22c3c259e44533199c347" }, "pyNetFT": { - "version": "2.1.1", - "sourceTag": "v2.1.1", + "version": "2.1.2", + "sourceTag": "v2.1.2", "repository": "https://github.com/netft/pyNetFT", - "sourceUrl": "https://github.com/netft/pyNetFT/tree/v2.1.1" + "sourceUrl": "https://github.com/netft/pyNetFT/tree/v2.1.2", + "sourceCommit": "7d7b9fb2bfb53e2cfc362c3d76d5a0222d385464" }, "netft-cli": { - "version": "0.2.1", - "sourceTag": "v0.2.1", + "version": "0.3.0", + "sourceTag": "v0.3.0", "repository": "https://github.com/netft/netft-cli", - "sourceUrl": "https://github.com/netft/netft-cli/tree/v0.2.1" + "sourceUrl": "https://github.com/netft/netft-cli/tree/v0.3.0", + "sourceCommit": "ed946edcf31d4a6fab06dadaa3826afc7522f161" }, "ros-netft": { - "version": "0.3.3", - "sourceTag": "0.3.3", + "version": "0.4.0", + "sourceTag": "0.4.0", "repository": "https://github.com/netft/ros-netft", - "sourceUrl": "https://github.com/netft/ros-netft/tree/0.3.3" + "sourceUrl": "https://github.com/netft/ros-netft/tree/0.4.0", + "sourceCommit": "e272550080e10a1f1bc71c2ce6499c556ecd538c" }, "netft-viewer": { - "version": "0.1.1", - "sourceTag": "v0.1.1", + "version": "0.2.1", + "sourceTag": "v0.2.1", "repository": "https://github.com/netft/netft-viewer", - "sourceUrl": "https://github.com/netft/netft-viewer/tree/v0.1.1" + "sourceUrl": "https://github.com/netft/netft-viewer/tree/v0.2.1", + "sourceCommit": "678da9903880849d1a673504e3f16c177e384bcc", + "releaseChannel": "development" } } } diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md index 07e7aa7..79fccf8 100644 --- a/docs/get-started/installation.md +++ b/docs/get-started/installation.md @@ -6,15 +6,17 @@ sidebar_position: 2 Choose one interface for the first connection. The CLI is the recommended commissioning tool because it can inspect configuration and run a bounded health check without a GUI, programming environment, or ROS graph. +Viewer 0.2.1 is a development prerelease without publisher signing. Its Windows installers are unsigned; macOS packages are not notarized. It is intended for development evaluation. + ## Supported releases -| Interface | Stable release | Preferred install | Platforms | -| -------------- | -------------- | ----------------------------------- | ------------------------------------------ | -| Net F/T CLI | 0.2.1 | GitHub release executable | Linux, macOS, Windows | -| Net F/T Viewer | 0.1.1 | GitHub release installer or archive | Linux, macOS, Windows | -| netft-cpp | 0.3.3 | CMake source build | Linux, macOS, Windows | -| pyNetFT | 2.1.1 | PyPI wheel | CPython 3.10–3.14 on Linux, macOS, Windows | -| ros-netft | 0.3.3 | ROS workspace source build | Supported ROS environments on Linux | +| Interface | Version | Preferred install | Platforms | +| -------------- | ------------------------------ | ----------------------------------- | ------------------------------------------ | +| Net F/T CLI | 0.3.0 | GitHub release executable | Linux, macOS, Windows | +| Net F/T Viewer | 0.2.1 (development prerelease) | GitHub release installer or archive | Linux, macOS, Windows | +| netft-cpp | 0.3.4 | CMake source build | Linux, macOS, Windows | +| pyNetFT | 2.1.2 | PyPI wheel | CPython 3.10–3.14 on Linux, macOS, Windows | +| ros-netft | 0.4.0 | ROS workspace source build | Supported ROS environments on Linux | Check the [compatibility reference](../references/compatibility.mdx) before installing a later release. @@ -52,7 +54,7 @@ No ROS installation is needed. The application is self-contained; host firewall Install the supported wheel: ```bash -python -m pip install pynetft==2.1.1 +python -m pip install pynetft==2.1.2 python -c "import pynetft; print(pynetft.__version__)" ``` @@ -63,7 +65,7 @@ Wheels include the native core and required curl implementation, so users do not Build and install netft-cpp with CMake: ```bash -git clone --branch v0.3.3 https://github.com/netft/netft-cpp.git +git clone --branch v0.3.4 https://github.com/netft/netft-cpp.git cd netft-cpp cmake -S . -B build/release \ -DCMAKE_BUILD_TYPE=Release \ @@ -83,7 +85,7 @@ Replace `lyrical` with another supported distribution when required: ```bash source /opt/ros/lyrical/setup.bash mkdir -p ~/netft_ws/src -git clone --branch 0.3.3 https://github.com/netft/ros-netft.git \ +git clone --branch 0.4.0 https://github.com/netft/ros-netft.git \ ~/netft_ws/src/netft_driver cd ~/netft_ws rosdep install --from-paths src --ignore-src -r -y diff --git a/docs/references/about.mdx b/docs/references/about.mdx index 469fa66..fa0f2bc 100644 --- a/docs/references/about.mdx +++ b/docs/references/about.mdx @@ -32,3 +32,17 @@ Hardware guidance in this site is based on the following ATI Industrial Automati ATI material is authoritative for physical installation, electrical limits, model-specific axes, calibration, firmware, filters, transformations, and persistent device settings. This site paraphrases and links to that material; it does not redistribute ATI manuals. + +## Maintenance and issue routing + +| Concern | Owning repository | +| ------------------------------------------------ | ----------------- | +| RDT protocol, calibration, native recovery | `netft-cpp` | +| Python delivery and compatibility API | `pyNetFT` | +| Terminal parsing, diagnostics, recording | `netft-cli` | +| ROS nodes, controllers, distribution integration | `ros-netft` | +| Desktop interaction, companion, packaging | `netft-viewer` | +| Shared user guides and source references | `netft-docs` | +| Organization navigation and policies | `.github` | + +Fix shared core behavior upstream, then verify each consumer's pinned source manifest and adaptations. The compatibility page uses the maintained component inventory; consult the ROS repository for the exact distribution contract and validation gaps. Candidate documentation explicitly identifies unreleased behavior. diff --git a/docs/references/compatibility.mdx b/docs/references/compatibility.mdx index 67ac8f5..736907b 100644 --- a/docs/references/compatibility.mdx +++ b/docs/references/compatibility.mdx @@ -4,7 +4,7 @@ import {ComponentVersions} from '@site/src/components/ReferenceTables'; -The listed application and integration releases use the netft-cpp 0.3.3 protocol core snapshot where applicable. They remain independently installable and versioned; users do not install netft-cpp separately for CLI, Viewer, pyNetFT wheels, or ros-netft. +The listed application and integration releases use the netft-cpp 0.3.4 protocol core snapshot where applicable. They remain independently installable and versioned; users do not install netft-cpp separately for CLI, Viewer, pyNetFT wheels, or ros-netft. The snapshot version identifies shared protocol behavior, not a runtime package dependency. Updating netft-cpp does not update an installed application automatically. Use the release notes of the interface you install to determine when a newer snapshot was adopted. diff --git a/docs/references/cpp-api/overview.mdx b/docs/references/cpp-api/overview.mdx index 3eeef04..6a4c061 100644 --- a/docs/references/cpp-api/overview.mdx +++ b/docs/references/cpp-api/overview.mdx @@ -4,7 +4,7 @@ sidebar_position: 1 # C++ API -This reference covers the complete public API of netft-cpp 0.3.3. Include only headers under `include/netft` and link the installed target `netft::netft`. Other headers, namespaces, worker types, and embedded copies in applications are private implementation details. +This reference covers the complete public API of netft-cpp 0.3.4. Include only headers under `include/netft` and link the installed target `netft::netft`. Other headers, namespaces, worker types, and embedded copies in applications are private implementation details. ## Entry points diff --git a/docs/references/data-formats/csv.mdx b/docs/references/data-formats/csv.mdx index f39e392..e80917d 100644 --- a/docs/references/data-formats/csv.mdx +++ b/docs/references/data-formats/csv.mdx @@ -38,3 +38,23 @@ force_unit,torque_unit `host_receive_timestamp_ns` uses the system clock. `elapsed_recording_time_ns` is monotonic from recording start. Configuration revision and unit columns keep calibration changes visible across reconnects. CLI and Viewer recorders write a temporary `.partial` file and promote it only after a clean queue drain and flush. A partial file is not proof of a complete capture. + +## Recording metadata (CLI 0.3.0 and Viewer 0.2.1) + +After successful data finalization, the candidate writes `.metadata.json` using schema version 1 and kind `netft-recording`. CLI and Viewer share these meanings: + +| Field | Meaning | +| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `producer`, `result` | `netft-cli` or `netft-viewer`; `complete` or CLI `interrupted` after a successful nonempty drain. | +| `accepted_samples`, `written_samples` | Samples accepted into the recording queue and rows successfully written. These are not power-loss durability guarantees. | +| `sample_span_seconds` | Nonnegative host receive-time span between first and last written sample; zero for fewer than two samples. | +| `configuration_revisions` | Unique revision identifiers in written rows, scoped to the producer session. They do not contain full calibration snapshots. | +| `force_units`, `torque_units` | Unique native unit symbols observed in written rows. | +| `recorded_rdt_gaps` | Sum of forward sequence gaps between written rows, using unsigned wraparound and ignoring backward jumps. Pauses, rate filtering and reconnects can contribute; this is not the SDK packet-loss counter. | +| `pause_count` | Accepted Viewer pauses; CLI has no pause operation and reports zero. | +| `reconnect_count` | CLI SDK reconnect counter for the run; `null` for Viewer because its recorder does not own connection health. | +| `error` | `null` on successful finalization. Failure details remain in the CLI error or Viewer recorder state. | + +Partial or failed captures do not receive a completed summary. Metadata creation and promotion follow the data file's overwrite policy. The two files are finalized separately: a metadata failure reports an error and preserves the completed data file, and may leave a metadata partial for recovery. Keep both files when archiving a successful capture. + +The CLI candidate appends `configuration_revision` to CSV without moving existing columns and adds it inside the NDJSON sample object. Viewer already records revisions in each CSV row. Existing published releases retain their documented formats until these changes are released. diff --git a/docs/references/python-api/overview.mdx b/docs/references/python-api/overview.mdx index 7158b3c..ef06c67 100644 --- a/docs/references/python-api/overview.mdx +++ b/docs/references/python-api/overview.mdx @@ -7,7 +7,7 @@ sidebar_position: 1 import pythonManifest from '@site/data/reference/python.json'; import {ApiType} from '@site/src/components/Reference'; -This reference covers the public names exported by pyNetFT 2.1.1. Import them from `pynetft`; `pynetft._native` is private and is not a compatibility surface. +This reference covers the public names exported by pyNetFT 2.1.2. Import them from `pynetft`; `pynetft._native` is private and is not a compatibility surface. | Need | API | | -------------------------------------- | ---------------------------------------------------- | diff --git a/docs/tutorials/cli/inspect-and-validate.md b/docs/tutorials/cli/inspect-and-validate.md index ade7dbf..dfc4230 100644 --- a/docs/tutorials/cli/inspect-and-validate.md +++ b/docs/tutorials/cli/inspect-and-validate.md @@ -1,6 +1,6 @@ # Inspect and validate a sensor -Net F/T CLI 0.2.1 is the preferred tool for commissioning, bounded health checks, scripting, live observation, loss-aware recording, and explicit bias. +Net F/T CLI 0.3.0 is the preferred tool for commissioning, bounded health checks, scripting, live observation, loss-aware recording, and explicit bias. ## Inspect and check diff --git a/docs/tutorials/ros/standalone.md b/docs/tutorials/ros/standalone.md index d451190..d328a16 100644 --- a/docs/tutorials/ros/standalone.md +++ b/docs/tutorials/ros/standalone.md @@ -1,6 +1,6 @@ # ROS standalone driver -ros-netft 0.3.3 provides a standalone wrench driver for current ROS 2 distributions and legacy source support for ROS 1 Noetic. The ROS package name is `netft_driver`. +ros-netft 0.4.0 provides a standalone wrench driver for current ROS 2 distributions and legacy source support for ROS 1 Noetic. The ROS package name is `netft_driver`. ## Support and installation @@ -85,7 +85,7 @@ RDT does not acknowledge the bias command. A successful service response means t ```bash source /opt/ros/noetic/setup.bash mkdir -p ~/netft_ws/src -git clone --branch 0.3.3 https://github.com/netft/ros-netft.git \ +git clone --branch 0.4.0 https://github.com/netft/ros-netft.git \ ~/netft_ws/src/netft_driver cd ~/netft_ws rosdep update --include-eol-distros diff --git a/docs/tutorials/sdks/cpp.md b/docs/tutorials/sdks/cpp.md index 46b87d5..3ba1ebe 100644 --- a/docs/tutorials/sdks/cpp.md +++ b/docs/tutorials/sdks/cpp.md @@ -1,6 +1,6 @@ # C++ SDK tutorial -netft-cpp 0.3.3 is a standalone C++17 SDK for configuration discovery, RDT acquisition, calibrated samples, health, and explicit recovery. +netft-cpp 0.3.4 is a standalone C++17 SDK for configuration discovery, RDT acquisition, calibrated samples, health, and explicit recovery. ## Build and consume diff --git a/docs/tutorials/sdks/python.md b/docs/tutorials/sdks/python.md index 7a13b4b..d672285 100644 --- a/docs/tutorials/sdks/python.md +++ b/docs/tutorials/sdks/python.md @@ -1,11 +1,11 @@ # Python SDK tutorial -pyNetFT 2.1.1 is a synchronous typed Python interface to the shared native core. Supported wheels cover CPython 3.10–3.14 on Linux and macOS x86_64/ARM64 and Windows x86_64. They include curl and require no NumPy. +pyNetFT 2.1.2 is a synchronous typed Python interface to the shared native core. Supported wheels cover CPython 3.10–3.14 on Linux and macOS x86_64/ARM64 and Windows x86_64. They include curl and require no NumPy. ## Install and read ```bash -python -m pip install pynetft==2.1.1 +python -m pip install pynetft==2.1.2 ``` ```python diff --git a/docs/tutorials/viewer/connect-and-inspect.md b/docs/tutorials/viewer/connect-and-inspect.md index e18313e..cc8c9b4 100644 --- a/docs/tutorials/viewer/connect-and-inspect.md +++ b/docs/tutorials/viewer/connect-and-inspect.md @@ -1,6 +1,6 @@ # Connect and inspect live data -Net F/T Viewer 0.1.1 is a cross-platform desktop application for one sensor. It combines raw counts, calibrated values, units, connection health, charts, bias, and buffered CSV recording without requiring ROS. +Net F/T Viewer 0.2.1 is a cross-platform desktop application for one sensor. It combines raw counts, calibrated values, units, connection health, charts, bias, and buffered CSV recording without requiring ROS. ![Net F/T Viewer showing connection controls, status, live values, and six chart panels.](/img/netft-viewer.png) diff --git a/package.json b/package.json index 040b120..1b7be7f 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "serve": "docusaurus serve", "start": "docusaurus start", "test": "node --test", - "test:unit": "node --test test/check-built-site.test.mjs test/validate-ecosystem.test.mjs test/validate-sources.test.mjs test/reference-manifest.test.mjs test/reference-update.test.mjs", + "test:unit": "node --test test/check-built-site.test.mjs test/validate-ecosystem.test.mjs test/validate-sources.test.mjs test/reference-manifest.test.mjs test/reference-update.test.mjs test/check-examples.test.mjs test/source-identity.test.mjs", "test:built": "node --test test/reference-rendering.test.mjs test/api-coverage.test.mjs test/interface-reference-coverage.test.mjs test/site-chrome.test.mjs", "typecheck": "tsc", "update:references": "node scripts/reference/update.mjs", diff --git a/scripts/check-examples.mjs b/scripts/check-examples.mjs index e6c46bd..3d24354 100644 --- a/scripts/check-examples.mjs +++ b/scripts/check-examples.mjs @@ -1,52 +1,58 @@ -import {access} from 'node:fs/promises'; +import {access, readFile} from 'node:fs/promises'; import {spawnSync} from 'node:child_process'; import path from 'node:path'; +const allowMissing = process.argv.includes('--allow-missing'); +if (process.argv.slice(2).some((arg) => arg !== '--allow-missing')) { + throw new Error('usage: check-examples.mjs [--allow-missing]'); +} +const versions = JSON.parse( + await readFile('data/reference/versions.json', 'utf8'), +); +const cppRoot = path.resolve( + process.env.NETFT_CPP_ROOT || '.dependencies/netft-cpp', +); +const python = process.env.PYTHON || 'python3'; function run(command, args) { - const result = spawnSync(command, args, {encoding: 'utf8'}); + const result = spawnSync(command, args, {encoding: 'utf8', timeout: 60000}); if (result.status !== 0) { - process.stderr.write(result.stderr || result.stdout); - process.exit(result.status ?? 1); + throw new Error( + result.stderr || result.stdout || `${command} failed: ${result.error}`, + ); } } -const cppRoot = path.resolve('..', 'netft-cpp'); +let haveCpp = true; try { - await access(path.join(cppRoot, 'include', 'netft', 'client.hpp')); + await access(path.join(cppRoot, 'include/netft/client.hpp')); } catch { - console.log( - 'Skipping C++ example: sibling netft-cpp checkout is unavailable.', - ); - run(process.env.PYTHON || 'python3', [ - '-m', - 'py_compile', - 'examples/python/read_sensor.py', - ]); - process.exit(0); + haveCpp = false; + if (!allowMissing) + throw new Error( + 'C++ SDK headers missing: set NETFT_CPP_ROOT or install the fixed .dependencies checkout', + ); + console.log('Explicitly skipped C++ example: SDK headers unavailable.'); } - -const compilerArgs = [ - '-std=c++17', - '-fsyntax-only', - `-I${path.join(cppRoot, 'include')}`, - 'examples/cpp/read_sensor.cpp', -]; -const compiler = process.env.CXX || 'c++'; -const probe = spawnSync(compiler, ['--version'], {encoding: 'utf8'}); -if (probe.error?.code === 'ENOENT') { - run('pixi', [ - 'run', - '--manifest-path', - path.join(cppRoot, 'pixi.toml'), - compiler, - ...compilerArgs, +if (haveCpp) { + run(process.env.CXX || 'c++', [ + '-std=c++17', + '-fsyntax-only', + `-I${path.join(cppRoot, 'include')}`, + 'examples/cpp/read_sensor.cpp', ]); +} +run(python, ['-m', 'py_compile', 'examples/python/read_sensor.py']); +const probe = spawnSync(python, ['-c', 'import pynetft'], { + encoding: 'utf8', + timeout: 60000, +}); +if (probe.status !== 0) { + if (!allowMissing) throw new Error(probe.stderr || 'pynetft is unavailable'); + console.log('Explicitly skipped Python import: pynetft unavailable.'); } else { - run(compiler, compilerArgs); + run(python, [ + '-c', + 'import importlib.metadata, runpy, sys; assert importlib.metadata.version("pynetft") == sys.argv[1], "pynetft version mismatch"; runpy.run_path("examples/python/read_sensor.py", run_name="__docs_check__")', + versions.components.pyNetFT.version, + ]); } - -run(process.env.PYTHON || 'python3', [ - '-m', - 'py_compile', - 'examples/python/read_sensor.py', -]); diff --git a/scripts/reference/extract-cli.mjs b/scripts/reference/extract-cli.mjs index 37d476c..b3b7c2b 100644 --- a/scripts/reference/extract-cli.mjs +++ b/scripts/reference/extract-cli.mjs @@ -2,120 +2,46 @@ import {spawnSync} from 'node:child_process'; import {existsSync} from 'node:fs'; import {join} from 'node:path'; -const commandNames = [ - 'info', - 'check', - 'monitor', - 'record', - 'bias', - 'completion', -]; - -const exitMeanings = new Map([ - [0, 'Completed successfully'], - [2, 'Invalid command line or configuration'], - [3, 'Sensor discovery failed'], - [4, 'Stream connection or acquisition failed'], - [5, 'Sensor reported a fault'], - [6, 'Input/output operation failed'], - [7, 'A check acceptance criterion failed'], - [8, 'Recording integrity could not be guaranteed'], - [130, 'Interrupted'], -]); - -export function parseCliHelp(id, output) { - const lines = output.split(/\r?\n/); - const synopsis = lines.find((line) => line.startsWith('Usage: '))?.slice(7); - if (!synopsis) throw new Error(`missing CLI synopsis for ${id}`); - const sections = new Map(); - let section = 'description'; - sections.set(section, []); - for (const line of lines.slice(1)) { - const heading = line.match(/^(Options|Examples|Exit status):$/)?.[1]; - if (heading) { - section = heading; - sections.set(section, []); - continue; - } - sections.get(section).push(line); - } - const options = []; - const optionLines = sections.get('Options') ?? []; - for (let index = 0; index < optionLines.length; ++index) { - const match = optionLines[index].match( - /^\s*(?:-([A-Za-z]),\s*)?--([a-z-]+)(?:\s+(.+))?$/, - ); - if (!match) continue; - const description = optionLines[index + 1]?.trim() ?? ''; - options.push({ - id: match[2], - longName: match[2], - ...(match[1] ? {shortName: match[1]} : {}), - valueType: match[3] ?? 'flag', - values: match[3]?.includes('|') ? match[3].split('|') : [], - description, - }); - } - const exitLine = (sections.get('Exit status') ?? []).join(' '); - const exitStatuses = [...exitLine.matchAll(/\d+/g)].map((match) => - Number(match[0]), - ); - const examples = (sections.get('Examples') ?? []) - .map((line) => line.trim()) - .filter(Boolean); - const description = (sections.get('description') ?? []) - .map((line) => line.trim()) - .filter(Boolean) - .join(' '); - return { - id, - name: id, - synopsis, - description, - optionIds: options.map((option) => option.id), - options, - positionals: [], - examples, - exitStatuses, - }; -} - export function extractCli(root, metadata) { const executable = [ + process.env.NETFT_CLI_EXECUTABLE, join(root, 'build/netft'), join(root, 'build/release/netft'), - ].find(existsSync); - if (!executable) { + ] + .filter(Boolean) + .find(existsSync); + if (!executable) throw new Error( - 'netft-cli executable not found; build the stable checkout before extraction', + 'netft-cli executable not found; build the exact checkout before extraction', ); - } - const commands = commandNames.map((name) => { - const result = spawnSync(executable, ['help', name], {encoding: 'utf8'}); - if (result.status !== 0) - throw new Error(`failed to read netft help ${name}: ${result.stderr}`); - return parseCliHelp(name, result.stdout); + const result = spawnSync(executable, ['--schema'], { + encoding: 'utf8', + timeout: 10000, + maxBuffer: 1024 * 1024, }); - const options = new Map(); - for (const command of commands) { - for (const option of command.options) options.set(option.id, option); - delete command.options; - } - const codes = [ - ...new Set(commands.flatMap((command) => command.exitStatuses)), - ].sort((a, b) => a - b); - return { - schemaVersion: 1, - kind: 'cli', - component: 'netft-cli', - ...metadata, - options: [...options.values()].sort((left, right) => - left.id.localeCompare(right.id), - ), - commands, - exitStatuses: codes.map((code) => ({ - code, - meaning: exitMeanings.get(code) ?? 'Undocumented', - })), - }; + if (result.status !== 0) + throw new Error( + 'selected CLI must support --schema; use an explicit unpublished candidate until the interface is released', + ); + const manifest = JSON.parse(result.stdout); + validateCliIdentity(manifest, metadata); + const {sourceDirty, ...data} = manifest; + return {...data, ...metadata}; +} + +export function validateCliIdentity(manifest, metadata) { + if ( + manifest.schemaVersion !== 1 || + manifest.kind !== 'cli' || + manifest.component !== 'netft-cli' + ) + throw new Error('unsupported CLI machine interface'); + if ( + manifest.version !== metadata.version || + manifest.sourceCommit !== metadata.sourceCommit || + manifest.sourceDirty !== false + ) + throw new Error( + 'CLI executable identity does not match clean source checkout', + ); } diff --git a/scripts/reference/manifest.mjs b/scripts/reference/manifest.mjs index f3729af..438890c 100644 --- a/scripts/reference/manifest.mjs +++ b/scripts/reference/manifest.mjs @@ -30,9 +30,17 @@ function validateMetadata(manifest, expectedKind) { `expected ${expectedKind} manifest, received ${manifest.kind}`, ); } - for (const field of ['component', 'version', 'sourceTag', 'sourceUrl']) { + for (const field of [ + 'component', + 'version', + 'sourceTag', + 'sourceUrl', + 'sourceCommit', + ]) { requireString(manifest[field], field); } + if (!/^[0-9a-f]{40}$/.test(manifest.sourceCommit)) + throw new Error('sourceCommit must be a full commit SHA'); if (!manifest.sourceUrl.startsWith('https://github.com/netft/')) { throw new Error('sourceUrl must refer to the netft organization'); } @@ -125,3 +133,16 @@ export function loadReferenceManifest(file, expectedKind) { expectedKind, ); } + +export function validateReferenceIdentity(manifest, component) { + for (const field of ['version', 'sourceTag', 'sourceCommit', 'sourceUrl']) { + if (manifest[field] !== component[field]) + throw new Error(`reference ${field} does not match component versions`); + } + if ( + (manifest.publication ?? 'released') !== + (component.publication ?? 'released') + ) + throw new Error('reference publication does not match component versions'); + return manifest; +} diff --git a/scripts/reference/source-identity.mjs b/scripts/reference/source-identity.mjs new file mode 100644 index 0000000..2960d20 --- /dev/null +++ b/scripts/reference/source-identity.mjs @@ -0,0 +1,56 @@ +import {spawnSync} from 'node:child_process'; +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; + +function git(root, args) { + const result = spawnSync('git', ['-C', root, ...args], { + encoding: 'utf8', + timeout: 10000, + }); + if (result.status !== 0) + throw new Error(`cannot verify source checkout: ${result.stderr}`); + return result.stdout.trim(); +} +export function verifySourceIdentity(root, component, metadata) { + if (!/^[0-9a-f]{40}$/.test(metadata.sourceCommit ?? '')) + throw new Error('full sourceCommit is required'); + if (git(root, ['rev-parse', 'HEAD']) !== metadata.sourceCommit) + throw new Error('source HEAD does not match reference identity'); + if (git(root, ['status', '--porcelain'])) + throw new Error('source checkout is dirty'); + const repository = `https://github.com/netft/${component}`; + if ( + git(root, ['remote', 'get-url', 'origin']).replace(/\.git$/, '') !== + repository + ) + throw new Error('source repository does not match component'); + if (metadata.sourceTag === 'unreleased') { + if ( + metadata.publication !== 'unreleased' || + metadata.sourceUrl !== `${repository}/tree/${metadata.sourceCommit}` + ) + throw new Error('candidate publication identity is invalid'); + } else if ( + git(root, ['rev-parse', `${metadata.sourceTag}^{commit}`]) !== + metadata.sourceCommit || + metadata.sourceUrl !== `${repository}/tree/${metadata.sourceTag}` + ) { + throw new Error('source tag does not match reference identity'); + } + let version; + if (component === 'pyNetFT') { + version = readFileSync(join(root, 'pyproject.toml'), 'utf8').match( + /^version\s*=\s*"([^"]+)"/m, + )?.[1]; + } else if (component === 'ros-netft') { + version = readFileSync(join(root, 'package.xml'), 'utf8').match( + /([^<]+)<\/version>/, + )?.[1]; + } else { + version = readFileSync(join(root, 'CMakeLists.txt'), 'utf8').match( + /project\([^)]*\bVERSION\s+([0-9.]+)/, + )?.[1]; + } + if (version !== metadata.version) + throw new Error('source version does not match reference version'); +} diff --git a/scripts/reference/update.mjs b/scripts/reference/update.mjs index 97f8161..a5395b1 100644 --- a/scripts/reference/update.mjs +++ b/scripts/reference/update.mjs @@ -1,6 +1,8 @@ import {readFileSync, writeFileSync} from 'node:fs'; import {resolve} from 'node:path'; +import {verifySourceIdentity} from './source-identity.mjs'; + import {extractCli} from './extract-cli.mjs'; import {extractCpp} from './extract-cpp.mjs'; import {extractPython} from './extract-python.mjs'; @@ -26,6 +28,8 @@ function metadata(component, versions) { const value = versions.components[component]; return { version: value.version, + sourceCommit: value.sourceCommit, + publication: value.publication ?? 'released', sourceTag: value.sourceTag, sourceUrl: value.sourceUrl, }; @@ -33,8 +37,21 @@ function metadata(component, versions) { export function updateReferenceManifests(paths) { const versions = JSON.parse( - readFileSync(resolve('data/reference/versions.json'), 'utf8'), + readFileSync( + resolve( + process.env.NETFT_REFERENCE_VERSIONS || 'data/reference/versions.json', + ), + 'utf8', + ), ); + for (const [key, component] of Object.entries({ + cpp: 'netft-cpp', + python: 'pyNetFT', + cli: 'netft-cli', + ros: 'ros-netft', + })) { + verifySourceIdentity(paths[key], component, metadata(component, versions)); + } const manifests = { 'cpp.json': validateReferenceManifest( extractCpp(paths.cpp, metadata('netft-cpp', versions)), @@ -55,7 +72,7 @@ export function updateReferenceManifests(paths) { }; for (const [filename, manifest] of Object.entries(manifests)) { writeFileSync( - resolve('data/reference', filename), + resolve(process.env.NETFT_REFERENCE_OUTPUT || 'data/reference', filename), `${JSON.stringify(manifest, null, 2)}\n`, ); } diff --git a/scripts/validate-ecosystem.mjs b/scripts/validate-ecosystem.mjs index 2acd2d4..e842345 100644 --- a/scripts/validate-ecosystem.mjs +++ b/scripts/validate-ecosystem.mjs @@ -102,8 +102,13 @@ if (!Array.isArray(categories) || categories.length === 0) { ); } - if (!/^\d+\.\d+\.\d+$/.test(offering.stableVersion ?? '')) { - failures.push(`${offering.id}: stable version must use X.Y.Z`); + if (!/^\d+\.\d+\.\d+$/.test(offering.version ?? '')) { + failures.push(`${offering.id}: version must use X.Y.Z`); + } + if ( + !['stable', 'development'].includes(offering.releaseChannel ?? 'stable') + ) { + failures.push(`${offering.id}: unsupported release channel`); } const supportedPlatforms = new Set(['Linux', 'macOS', 'Windows']); if ( diff --git a/scripts/validate-reference-manifests.mjs b/scripts/validate-reference-manifests.mjs index 934d27f..8356e9d 100644 --- a/scripts/validate-reference-manifests.mjs +++ b/scripts/validate-reference-manifests.mjs @@ -1,14 +1,35 @@ -import {existsSync} from 'node:fs'; +import {readFileSync} from 'node:fs'; import {resolve} from 'node:path'; -import {loadReferenceManifest} from './reference/manifest.mjs'; +import { + loadReferenceManifest, + validateReferenceIdentity, +} from './reference/manifest.mjs'; -for (const [name, kind] of [ - ['cpp.json', 'cpp-api'], - ['python.json', 'python-api'], - ['cli.json', 'cli'], - ['ros.json', 'ros'], +const versions = JSON.parse( + readFileSync('data/reference/versions.json', 'utf8'), +).components; +const catalog = JSON.parse(readFileSync('data/ecosystem.json', 'utf8')); +for (const offering of catalog.categories.flatMap( + (category) => category.offerings, +)) { + const name = offering.repository.split('/').at(-1); + const component = versions[name]; + if ( + !component || + offering.version !== component.version || + offering.repository !== component.repository + ) + throw new Error(`catalog identity does not match versions: ${name}`); +} +for (const [name, kind, component] of [ + ['cpp.json', 'cpp-api', 'netft-cpp'], + ['python.json', 'python-api', 'pyNetFT'], + ['cli.json', 'cli', 'netft-cli'], + ['ros.json', 'ros', 'ros-netft'], ]) { - const file = resolve('data/reference', name); - if (existsSync(file)) loadReferenceManifest(file, kind); + validateReferenceIdentity( + loadReferenceManifest(resolve('data/reference', name), kind), + versions[component], + ); } diff --git a/src/components/ReferenceTables/index.tsx b/src/components/ReferenceTables/index.tsx index fc23fcc..b14052a 100644 --- a/src/components/ReferenceTables/index.tsx +++ b/src/components/ReferenceTables/index.tsx @@ -10,7 +10,7 @@ export function ComponentVersions(): ReactNode { Interface - Stable version + Version Platforms Release @@ -19,7 +19,11 @@ export function ComponentVersions(): ReactNode { {offerings.map((offering) => ( {offering.name} - {offering.stableVersion} + + {offering.version} + {offering.releaseChannel === 'development' && + ' (development prerelease)'} + {offering.platforms.join(', ')} Releases diff --git a/src/types/reference.ts b/src/types/reference.ts index cc1928e..87a875b 100644 --- a/src/types/reference.ts +++ b/src/types/reference.ts @@ -45,6 +45,8 @@ export interface ApiManifest { kind: ApiKind; component: string; version: string; + sourceCommit: string; + publication?: 'released' | 'unreleased'; sourceTag: string; sourceUrl: string; symbols: ApiSymbol[]; @@ -65,7 +67,12 @@ export interface CliCommand { synopsis: string; description?: string; optionIds: string[]; - positionals: ApiParameter[]; + positionals: Array<{ + name: string; + valueType: string; + required: boolean; + values: string[]; + }>; examples: string[]; exitStatuses: number[]; } @@ -75,6 +82,8 @@ export interface CliManifest { kind: 'cli'; component: string; version: string; + sourceCommit: string; + publication?: 'released' | 'unreleased'; sourceTag: string; sourceUrl: string; options: CliOption[]; @@ -104,6 +113,8 @@ export interface RosManifest { kind: 'ros'; component: string; version: string; + sourceCommit: string; + publication?: 'released' | 'unreleased'; sourceTag: string; sourceUrl: string; pluginClass: string; diff --git a/test/check-examples.test.mjs b/test/check-examples.test.mjs new file mode 100644 index 0000000..032be50 --- /dev/null +++ b/test/check-examples.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import test from 'node:test'; + +test('an isolated checkout cannot silently pass without SDK headers', () => { + const directory = mkdtempSync(join(tmpdir(), 'netft-docs-examples-')); + try { + const result = spawnSync(process.execPath, ['scripts/check-examples.mjs'], { + env: {...process.env, NETFT_CPP_ROOT: directory}, + encoding: 'utf8', + timeout: 10000, + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /SDK headers missing/); + } finally { + rmSync(directory, {recursive: true, force: true}); + } +}); diff --git a/test/reference-manifest.test.mjs b/test/reference-manifest.test.mjs index ba9f2a5..b291f8f 100644 --- a/test/reference-manifest.test.mjs +++ b/test/reference-manifest.test.mjs @@ -26,6 +26,7 @@ function apiManifest(overrides = {}) { component: 'netft-cpp', version: '0.3.3', sourceTag: 'v0.3.3', + sourceCommit: '1'.repeat(40), sourceUrl: 'https://github.com/netft/netft-cpp/tree/v0.3.3', symbols: [apiSymbol()], ...overrides, @@ -100,6 +101,7 @@ test('rejects a CLI command that references an unknown option', () => { component: 'netft-cli', version: '0.2.0', sourceTag: 'v0.2.0', + sourceCommit: '1'.repeat(40), sourceUrl: 'https://github.com/netft/netft-cli/tree/v0.2.0', options: [], commands: [ @@ -120,3 +122,33 @@ test('rejects a CLI command that references an unknown option', () => { /unknown option: format/, ); }); + +test('reference identities reject drift from the component version inventory', async () => { + const {validateReferenceIdentity} = + await import('../scripts/reference/manifest.mjs'); + const component = { + version: '1.2.3', + sourceTag: 'v1.2.3', + sourceCommit: 'a'.repeat(40), + sourceUrl: 'https://github.com/netft/netft-cli/tree/v1.2.3', + }; + assert.equal( + validateReferenceIdentity({...component}, component).version, + '1.2.3', + ); + for (const field of ['version', 'sourceCommit', 'sourceTag', 'sourceUrl']) { + assert.throws( + () => + validateReferenceIdentity({...component, [field]: 'wrong'}, component), + /does not match/, + ); + } + assert.throws( + () => + validateReferenceIdentity( + {...component, publication: 'unreleased'}, + component, + ), + /publication/, + ); +}); diff --git a/test/reference-rendering.test.mjs b/test/reference-rendering.test.mjs index 0cd9cc1..7da98f2 100644 --- a/test/reference-rendering.test.mjs +++ b/test/reference-rendering.test.mjs @@ -10,9 +10,14 @@ test('built C++ Client reference exposes the declaration and stable method ancho assert.match(html, /netft::Client/); assert.match(html, /void start\(SampleCallback callback\)/); assert.match(html, /id="start"/); - assert.match( - html, - /https:\/\/github\.com\/netft\/netft-cpp\/tree\/v0\.3\.3\/include\/netft\/client\.hpp/, + const {components} = JSON.parse( + readFileSync('data/reference/versions.json', 'utf8'), + ); + assert.ok( + html.includes( + `${components['netft-cpp'].sourceUrl}/include/netft/client.hpp`, + ), + 'Client declaration must link to the documented SDK release', ); assert.doesNotMatch(html, /pynetft\._native/); }); diff --git a/test/reference-update.test.mjs b/test/reference-update.test.mjs index d93250a..8c5977b 100644 --- a/test/reference-update.test.mjs +++ b/test/reference-update.test.mjs @@ -6,7 +6,7 @@ import {join} from 'node:path'; import {test} from 'node:test'; import {extractCpp} from '../scripts/reference/extract-cpp.mjs'; -import {parseCliHelp} from '../scripts/reference/extract-cli.mjs'; +import {validateCliIdentity} from '../scripts/reference/extract-cli.mjs'; import {extractPython} from '../scripts/reference/extract-python.mjs'; import {extractRos} from '../scripts/reference/extract-ros.mjs'; import {parseSourcePaths} from '../scripts/reference/update.mjs'; @@ -80,29 +80,26 @@ test('ROS extraction requires shared defaults to agree', () => { ); }); -test('CLI extraction preserves synopsis, options, and exit statuses', () => { - const command = parseCliHelp( - 'check', - `Usage: netft check [HOST] [OPTIONS] - -Run a bounded sensor stream health check. - -Options: - --duration DURATION - Stop after a bounded duration. - --max-loss NUMBER - Allow at most this packet loss percentage. - -Examples: - netft check sensor.example --duration 5s - -Exit status: - 0, 2, 3, 4, 5, 6, 7, 130 -`, - ); - assert.equal(command.synopsis, 'netft check [HOST] [OPTIONS]'); - assert.deepEqual(command.optionIds, ['duration', 'max-loss']); - assert.deepEqual(command.exitStatuses, [0, 2, 3, 4, 5, 6, 7, 130]); +test('CLI extraction rejects stale, dirty and unsupported executable identities', () => { + const metadata = {version: '0.2.1', sourceCommit: '1'.repeat(40)}; + const manifest = { + schemaVersion: 1, + kind: 'cli', + component: 'netft-cli', + ...metadata, + sourceDirty: false, + }; + assert.doesNotThrow(() => validateCliIdentity(manifest, metadata)); + for (const change of [ + {sourceCommit: '2'.repeat(40)}, + {sourceDirty: true}, + {version: '0.2.0'}, + {schemaVersion: 2}, + ]) { + assert.throws(() => + validateCliIdentity({...manifest, ...change}, metadata), + ); + } }); test('Python extraction follows public exports and excludes private helpers', () => { diff --git a/test/source-identity.test.mjs b/test/source-identity.test.mjs new file mode 100644 index 0000000..3323e34 --- /dev/null +++ b/test/source-identity.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {mkdtempSync, writeFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import test from 'node:test'; +import {verifySourceIdentity} from '../scripts/reference/source-identity.mjs'; + +test('reference provenance binds tag, version and clean checkout to a real commit', () => { + const root = mkdtempSync(join(tmpdir(), 'netft-reference-git-')); + const git = (...args) => { + const result = spawnSync('git', ['-C', root, ...args], {encoding: 'utf8'}); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + }; + try { + git('init'); + git('config', 'user.email', 'test@example.invalid'); + git('config', 'user.name', 'Fixture'); + git('remote', 'add', 'origin', 'https://github.com/netft/netft-cpp.git'); + writeFileSync( + join(root, 'CMakeLists.txt'), + 'project(netft VERSION 1.2.3 LANGUAGES CXX)\n', + ); + git('add', '.'); + git('commit', '-m', 'release'); + git('tag', 'v1.2.3'); + const metadata = { + version: '1.2.3', + sourceTag: 'v1.2.3', + sourceCommit: git('rev-parse', 'HEAD'), + sourceUrl: 'https://github.com/netft/netft-cpp/tree/v1.2.3', + }; + assert.doesNotThrow(() => + verifySourceIdentity(root, 'netft-cpp', metadata), + ); + assert.throws( + () => + verifySourceIdentity(root, 'netft-cpp', { + ...metadata, + version: '1.2.4', + }), + /version/, + ); + writeFileSync(join(root, 'changed.txt'), 'new'); + assert.throws( + () => verifySourceIdentity(root, 'netft-cpp', metadata), + /dirty/, + ); + git('add', '.'); + git('commit', '-m', 'next'); + assert.throws( + () => verifySourceIdentity(root, 'netft-cpp', metadata), + /HEAD/, + ); + assert.throws( + () => + verifySourceIdentity(root, 'netft-cpp', { + ...metadata, + sourceCommit: git('rev-parse', 'HEAD'), + }), + /tag/, + ); + } finally { + rmSync(root, {recursive: true, force: true}); + } +}); diff --git a/test/validate-ecosystem.test.mjs b/test/validate-ecosystem.test.mjs index 4d8dfc2..5ef1cc4 100644 --- a/test/validate-ecosystem.test.mjs +++ b/test/validate-ecosystem.test.mjs @@ -38,7 +38,7 @@ const offering = { audience: 'C++ developers', docsPath: 'sdks/cpp', repository: 'https://github.com/netft/netft-cpp', - stableVersion: '0.3.3', + version: '0.3.3', platforms: ['Linux', 'macOS', 'Windows'], installMethods: ['source'], documentationOwner: 'netft-docs', @@ -134,12 +134,12 @@ test('rejects an offering whose documentation route is missing', async () => { assert.match(result.stderr, /documentation route/i); }); -test('rejects a malformed stable version', async () => { +test('rejects a malformed version', async () => { const catalog = structuredClone(validCatalog); - catalog.categories[0].offerings[0].stableVersion = 'latest'; + catalog.categories[0].offerings[0].version = 'latest'; const result = await runValidator(catalog, ['sdks/cpp']); assert.notEqual(result.status, 0); - assert.match(result.stderr, /stable version/i); + assert.match(result.stderr, /version/i); }); test('rejects an unsupported platform value', async () => { @@ -149,3 +149,18 @@ test('rejects an unsupported platform value', async () => { assert.notEqual(result.status, 0); assert.match(result.stderr, /platform/i); }); + +test('accepts explicitly labeled development releases', async () => { + const catalog = structuredClone(validCatalog); + catalog.categories[0].offerings[0].releaseChannel = 'development'; + const result = await runValidator(catalog, ['sdks/cpp']); + assert.equal(result.status, 0, result.stderr); +}); + +test('rejects unsupported release channels', async () => { + const catalog = structuredClone(validCatalog); + catalog.categories[0].offerings[0].releaseChannel = 'unknown'; + const result = await runValidator(catalog, ['sdks/cpp']); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /release channel/i); +});