From b7ffafcf697aa7523eadbe637e630e6a1e8d5c2d Mon Sep 17 00:00:00 2001 From: hanxudong159 <42986190+han-xudong@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:21:26 +0800 Subject: [PATCH 1/6] Require example dependencies and pin release build provenance --- .github/workflows/ci.yml | 24 +++++++++++ .gitignore | 2 + CONTRIBUTING.md | 4 ++ data/reference/versions.json | 6 ++- package.json | 2 +- scripts/check-examples.mjs | 82 +++++++++++++++++++----------------- test/check-examples.test.mjs | 21 +++++++++ 7 files changed, 100 insertions(+), 41 deletions(-) create mode 100644 test/check-examples.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44f9b13..42b25e3 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/CONTRIBUTING.md b/CONTRIBUTING.md index 7b6d7d5..2288800 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,3 +30,7 @@ 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. diff --git a/data/reference/versions.json b/data/reference/versions.json index c969758..f560707 100644 --- a/data/reference/versions.json +++ b/data/reference/versions.json @@ -5,13 +5,15 @@ "version": "0.3.3", "sourceTag": "v0.3.3", "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.3", + "sourceCommit": "3259b8576b16fb150b51e9ef9090366c5e6efcba" }, "pyNetFT": { "version": "2.1.1", "sourceTag": "v2.1.1", "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.1", + "sourceCommit": "7ed2e2061f942d963116d78f3de76b4680e6fc44" }, "netft-cli": { "version": "0.2.1", diff --git a/package.json b/package.json index 040b120..e0e1799 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: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/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}); + } +}); From b074ed20b1c062017509e278a83213ad73d72537 Mon Sep 17 00:00:00 2001 From: hanxudong159 <42986190+han-xudong@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:43:18 +0800 Subject: [PATCH 2/6] fix(docs): verify reference sources and consume typed CLI schema --- CONTRIBUTING.md | 6 ++ data/reference/cli.json | 4 +- data/reference/cpp.json | 4 +- data/reference/python.json | 4 +- data/reference/ros.json | 4 +- data/reference/versions.json | 9 +- docs/references/about.mdx | 16 +++ docs/references/data-formats/csv.mdx | 20 ++++ package.json | 2 +- scripts/reference/extract-cli.mjs | 144 +++++++------------------- scripts/reference/manifest.mjs | 10 +- scripts/reference/source-identity.mjs | 56 ++++++++++ scripts/reference/update.mjs | 21 +++- src/types/reference.ts | 13 ++- test/reference-manifest.test.mjs | 2 + test/reference-update.test.mjs | 45 ++++---- test/source-identity.test.mjs | 67 ++++++++++++ 17 files changed, 282 insertions(+), 145 deletions(-) create mode 100644 scripts/reference/source-identity.mjs create mode 100644 test/source-identity.test.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2288800..114b09b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,3 +34,9 @@ By contributing authored content under `docs/`, you agree to license that contri ## 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/reference/cli.json b/data/reference/cli.json index 221d577..a7e8887 100644 --- a/data/reference/cli.json +++ b/data/reference/cli.json @@ -283,5 +283,7 @@ "code": 130, "meaning": "Interrupted" } - ] + ], + "sourceCommit": "ea7d21d614fdee86a34cadf2a0b456158ecfa41b", + "publication": "released" } diff --git a/data/reference/cpp.json b/data/reference/cpp.json index a2e32c2..5bb9ac3 100644 --- a/data/reference/cpp.json +++ b/data/reference/cpp.json @@ -1084,5 +1084,7 @@ "values": [], "bases": [] } - ] + ], + "sourceCommit": "3259b8576b16fb150b51e9ef9090366c5e6efcba", + "publication": "released" } diff --git a/data/reference/python.json b/data/reference/python.json index 2b990b9..ecab049 100644 --- a/data/reference/python.json +++ b/data/reference/python.json @@ -1020,5 +1020,7 @@ ], "bases": ["str", "Enum"] } - ] + ], + "sourceCommit": "7ed2e2061f942d963116d78f3de76b4680e6fc44", + "publication": "released" } diff --git a/data/reference/ros.json b/data/reference/ros.json index a603118..9b91426 100644 --- a/data/reference/ros.json +++ b/data/reference/ros.json @@ -254,5 +254,7 @@ "type": "double", "description": "ros2_control force/torque state interface" } - ] + ], + "sourceCommit": "ed23dc848f9ba6652d693f56341cca2b8480a8f2", + "publication": "released" } diff --git a/data/reference/versions.json b/data/reference/versions.json index f560707..7922ebc 100644 --- a/data/reference/versions.json +++ b/data/reference/versions.json @@ -19,19 +19,22 @@ "version": "0.2.1", "sourceTag": "v0.2.1", "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.2.1", + "sourceCommit": "ea7d21d614fdee86a34cadf2a0b456158ecfa41b" }, "ros-netft": { "version": "0.3.3", "sourceTag": "0.3.3", "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.3.3", + "sourceCommit": "ed23dc848f9ba6652d693f56341cca2b8480a8f2" }, "netft-viewer": { "version": "0.1.1", "sourceTag": "v0.1.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.1.1", + "sourceCommit": "9929c63f4822771a417f32b91c33ce8d6344d642" } } } diff --git a/docs/references/about.mdx b/docs/references/about.mdx index 469fa66..11d31bd 100644 --- a/docs/references/about.mdx +++ b/docs/references/about.mdx @@ -32,3 +32,19 @@ 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 + +The organization has seven repositories: five runtime components, `netft-docs`, and `.github`. The component inventory and supported distribution metadata are maintained in `data/ecosystem.json`; each component owns its source, release checks and version-specific support contract. + +| 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/data-formats/csv.mdx b/docs/references/data-formats/csv.mdx index f39e392..2a658a8 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 (unreleased candidate) + +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/package.json b/package.json index e0e1799..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/check-examples.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/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..75ee21e 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'); } 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/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/reference-manifest.test.mjs b/test/reference-manifest.test.mjs index ba9f2a5..1c15513 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: [ 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}); + } +}); From 8da81f84014c7a547cbaf5b9fdc40ec93ac393fe Mon Sep 17 00:00:00 2001 From: hanxudong159 <42986190+han-xudong@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:46:14 +0800 Subject: [PATCH 3/6] style(ci): format pinned documentation workflow --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42b25e3..63ba7a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: "3.12" + python-version: '3.12' - name: Install the fixed Python package for examples run: python3 -m pip install 'pynetft==${{ steps.example-versions.outputs.pynetft_version }}' From d82fe067d60ca7b257f6cb1700ae4206f08a49be Mon Sep 17 00:00:00 2001 From: hanxudong159 <42986190+han-xudong@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:53:09 +0800 Subject: [PATCH 4/6] fix(docs): reject component version and reference identity drift --- scripts/reference/manifest.mjs | 8 ++++++++ scripts/validate-reference-manifests.mjs | 24 +++++++++++++++--------- test/reference-manifest.test.mjs | 10 ++++++++++ 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/scripts/reference/manifest.mjs b/scripts/reference/manifest.mjs index 75ee21e..a1263be 100644 --- a/scripts/reference/manifest.mjs +++ b/scripts/reference/manifest.mjs @@ -133,3 +133,11 @@ 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/validate-reference-manifests.mjs b/scripts/validate-reference-manifests.mjs index 934d27f..2d135e9 100644 --- a/scripts/validate-reference-manifests.mjs +++ b/scripts/validate-reference-manifests.mjs @@ -1,14 +1,20 @@ -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.stableVersion !== 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/test/reference-manifest.test.mjs b/test/reference-manifest.test.mjs index 1c15513..954457e 100644 --- a/test/reference-manifest.test.mjs +++ b/test/reference-manifest.test.mjs @@ -122,3 +122,13 @@ 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/); +}); From 8c91af782ec199033d0b79a1696964644470f978 Mon Sep 17 00:00:00 2001 From: hanxudong159 <42986190+han-xudong@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:53:31 +0800 Subject: [PATCH 5/6] style(docs): format component identity checks --- scripts/reference/manifest.mjs | 9 +++++-- scripts/validate-reference-manifests.mjs | 25 ++++++++++++++++---- test/reference-manifest.test.mjs | 30 ++++++++++++++++++++---- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/scripts/reference/manifest.mjs b/scripts/reference/manifest.mjs index a1263be..438890c 100644 --- a/scripts/reference/manifest.mjs +++ b/scripts/reference/manifest.mjs @@ -136,8 +136,13 @@ export function loadReferenceManifest(file, 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[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'); + if ( + (manifest.publication ?? 'released') !== + (component.publication ?? 'released') + ) + throw new Error('reference publication does not match component versions'); return manifest; } diff --git a/scripts/validate-reference-manifests.mjs b/scripts/validate-reference-manifests.mjs index 2d135e9..ed633f7 100644 --- a/scripts/validate-reference-manifests.mjs +++ b/scripts/validate-reference-manifests.mjs @@ -1,14 +1,26 @@ import {readFileSync} from 'node:fs'; import {resolve} from 'node:path'; -import {loadReferenceManifest, validateReferenceIdentity} from './reference/manifest.mjs'; +import { + loadReferenceManifest, + validateReferenceIdentity, +} from './reference/manifest.mjs'; -const versions = JSON.parse(readFileSync('data/reference/versions.json', 'utf8')).components; +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)) { +for (const offering of catalog.categories.flatMap( + (category) => category.offerings, +)) { const name = offering.repository.split('/').at(-1); const component = versions[name]; - if (!component || offering.stableVersion !== component.version || offering.repository !== component.repository) throw new Error(`catalog identity does not match versions: ${name}`); + if ( + !component || + offering.stableVersion !== 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'], @@ -16,5 +28,8 @@ for (const [name, kind, component] of [ ['cli.json', 'cli', 'netft-cli'], ['ros.json', 'ros', 'ros-netft'], ]) { - validateReferenceIdentity(loadReferenceManifest(resolve('data/reference', name), kind), versions[component]); + validateReferenceIdentity( + loadReferenceManifest(resolve('data/reference', name), kind), + versions[component], + ); } diff --git a/test/reference-manifest.test.mjs b/test/reference-manifest.test.mjs index 954457e..b291f8f 100644 --- a/test/reference-manifest.test.mjs +++ b/test/reference-manifest.test.mjs @@ -124,11 +124,31 @@ test('rejects a CLI command that references an unknown option', () => { }); 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'); + 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, [field]: 'wrong'}, component), + /does not match/, + ); } - assert.throws(() => validateReferenceIdentity({...component, publication: 'unreleased'}, component), /publication/); + assert.throws( + () => + validateReferenceIdentity( + {...component, publication: 'unreleased'}, + component, + ), + /publication/, + ); }); From 4ce16964de95922c4c01610b0723ac5afe3475a6 Mon Sep 17 00:00:00 2001 From: hanxudong159 <42986190+han-xudong@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:33:59 +0800 Subject: [PATCH 6/6] Exclude checked-out example dependencies from site Markdown lint --- .markdownlint-cli2.jsonc | 1 + 1 file changed, 1 insertion(+) 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/**", ], }