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/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 c969758..7922ebc 100644 --- a/data/reference/versions.json +++ b/data/reference/versions.json @@ -5,31 +5,36 @@ "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", "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 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-reference-manifests.mjs b/scripts/validate-reference-manifests.mjs index 934d27f..ed633f7 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.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/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-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}); + } +});