From 6bf8ae71d85e452310e174a8ed7e4ec11a4eea07 Mon Sep 17 00:00:00 2001 From: claude-bot-go Date: Mon, 27 Jul 2026 08:37:16 +0800 Subject: [PATCH] Fix #96: [milestone Milestone 2.1] Initialize @wasmagent/protocol package structure and version band mechanism --- README.md | 42 +++++++++++++++++++++++++++++ index.d.ts | 28 +++++++++++++++++++ index.js | 16 +++++++++++ package.json | 13 +++++++++ tests/loader.test.js | 55 ++++++++++++++++++++++++++++++++++++- tests/test_conformance.py | 57 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 tests/test_conformance.py diff --git a/README.md b/README.md index 7e31c83..13ed55d 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,48 @@ path = schema_path("aep-record") # pathlib.Path to the .json file - Every schema has at least one valid and one invalid conformance fixture under [`tests/fixtures/`](tests/fixtures/). CI rejects any schema without both. +### Version bands + +`@wasmagent/protocol` ships on a **version band** model so consumers pin a +compatibility window, not a single patch. A *band* is one minor line +(`0.1.x`, `0.2.x`, …). Every release inside a band is backward-compatible — +fixes and additive optional fields only. A new minor opens the next band; a +breaking change bumps the package **major**. Crossing a band boundary is always +an explicit consumer action. + +The current band is declared in `package.json#versionBand` (machine-readable) +and re-exported from the package as `versionBand`: + +| | | +|---|---| +| Band | `0.1` (alpha) | +| Recommended range | `~0.1.5` (stay on the `0.1.x` patch band) | +| Also accepted | `>=0.1.0 <0.2.0`, `^0.1.5`, `0.1.x` | +| Runtime band | Node `>=18` (`package.json#engines`) | + +**Declare the dependency like this:** + +```json +"dependencies": { + "@wasmagent/protocol": "~0.1.5" +} +``` + +`~0.1.5` is recommended for the alpha: you receive every backward-compatible +patch within the `0.1` band and are never auto-upgraded across a band boundary. +When the next additive minor (`0.2.x`) ships, opt in by bumping to `~0.2.0`; +when a breaking major ships, bump it consciously. Read the band at runtime: + +```ts +import { versionBand } from "@wasmagent/protocol"; +// versionBand.band === "0.1" +// versionBand.supportedRanges.recommended === "~0.1.5" +``` + +The Python distribution (`wasmagent-protocol` on PyPI) mirrors the same minor +band via its own package version and declares its runtime band with +`requires-python = ">=3.9"` in `pyproject.toml`. + See [`docs/CONTRACT-CHANGE-PROCESS.md`](docs/CONTRACT-CHANGE-PROCESS.md) for the full change workflow and [`docs/GOVERNANCE.md`](docs/GOVERNANCE.md) for maintainer and exit-condition policy. diff --git a/index.d.ts b/index.d.ts index 34c4dde..c81c6f0 100644 --- a/index.d.ts +++ b/index.d.ts @@ -19,12 +19,40 @@ export interface SchemaIndex { schemas: SchemaIndexEntry[]; } +export interface VersionBandRanges { + /** Range consumers are recommended to declare (stays within one band). */ + recommended: string; + /** Other ranges the band guarantees to satisfy. */ + alsoAccepted: string[]; +} + +export interface VersionBand { + /** Current minor-line band, e.g. "0.1". */ + band: string; + /** Lifecycle status of the band, e.g. "alpha". */ + status: string; + /** Human-readable summary of the band's compatibility guarantee. */ + summary: string; + supportedRanges: VersionBandRanges; + /** Semver/band policy consumers should follow. */ + policy: string; +} + /** Machine-readable registry of every canonical schema. */ export const index: SchemaIndex; /** All schemas as a plain object keyed by id. */ export const schemas: Record; +/** Published package version (mirrors package.json#version). */ +export const version: string; + +/** Declared runtime band (mirrors package.json#engines). */ +export const engines: { node: string }; + +/** Supported compatibility band (mirrors package.json#versionBand). */ +export const versionBand: VersionBand; + /** * Return the parsed JSON Schema for a registered schema id * (e.g. "aep-record", "constraint-ir"). Throws on unknown id. diff --git a/index.js b/index.js index dc7a00a..786a5ed 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,9 @@ const here = dirname(fileURLToPath(import.meta.url)); /** Machine-readable registry of every canonical schema. */ export const index = JSON.parse(readFileSync(join(here, 'schemas', 'index.json'), 'utf8')); +/** Package manifest (name, version, runtime band, version band). */ +const pkg = JSON.parse(readFileSync(join(here, 'package.json'), 'utf8')); + const byId = new Map(index.schemas.map((s) => [s.id, s])); /** @@ -30,3 +33,16 @@ export function getSchema(id) { /** All schemas as a plain object keyed by id. */ export const schemas = Object.fromEntries(index.schemas.map((s) => [s.id, getSchema(s.id)])); + +/** Published package version (mirrors package.json#version). */ +export const version = pkg.version; + +/** Declared runtime band (mirrors package.json#engines). */ +export const engines = pkg.engines; + +/** + * Supported compatibility band for this package (mirrors + * package.json#versionBand). Consumers should consult + * `versionBand.supportedRanges.recommended` when declaring a dependency range. + */ +export const versionBand = pkg.versionBand; diff --git a/package.json b/package.json index 8bd4f37..c96e989 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,19 @@ }, "license": "Apache-2.0", "type": "module", + "engines": { + "node": ">=18" + }, + "versionBand": { + "band": "0.1", + "status": "alpha", + "summary": "Current supported compatibility band for @wasmagent/protocol. All releases within the 0.1.x band are backward-compatible (fixes and additive optional fields only); a new minor or major release opens the next band.", + "supportedRanges": { + "recommended": "~0.1.5", + "alsoAccepted": [">=0.1.0 <0.2.0", "^0.1.5", "0.1.x"] + }, + "policy": "Consumers should declare a range that stays within one band (recommended: ~0.1.5). Additive changes bump the package minor and open the next band (0.2.x); breaking changes bump the package major. Crossing a band boundary is always an explicit consumer action. See README.md (Version bands)." + }, "main": "./index.js", "types": "./index.d.ts", "exports": { diff --git a/tests/loader.test.js b/tests/loader.test.js index 5d82b18..de48779 100644 --- a/tests/loader.test.js +++ b/tests/loader.test.js @@ -1,6 +1,19 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { test } from 'node:test'; -import { getSchema, index, schemas } from '../index.js'; +import { fileURLToPath } from 'node:url'; +import { + engines, + getSchema, + index, + schemas, + version, + versionBand, +} from '../index.js'; + +const pkg = JSON.parse( + readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'), +); test('registry lists at least one schema', () => { assert.ok(index.schemas.length >= 1); @@ -23,3 +36,43 @@ test('schemas map is keyed by id', () => { test('getSchema throws on unknown id', () => { assert.throws(() => getSchema('does-not-exist'), /unknown schema id/); }); + +test('package.json initializes @wasmagent/protocol with a version and runtime band', () => { + assert.equal(pkg.name, '@wasmagent/protocol'); + assert.match(pkg.version, /^\d+\.\d+\.\d+$/, 'package declares a semver version'); + assert.equal(pkg.version, version, 'exported version mirrors package.json'); + assert.equal( + typeof pkg.engines?.node, + 'string', + 'package declares an engines.node runtime band', + ); + assert.equal(engines.node, pkg.engines.node, 'exported engines mirrors package.json'); +}); + +test('version band mechanism is declared in package.json and re-exported', () => { + // The band must match the current version's major.minor line. + const [major, minor] = pkg.version.split('.'); + assert.ok(pkg.versionBand, 'package.json declares a versionBand mechanism'); + assert.deepEqual(versionBand, pkg.versionBand, 'versionBand is re-exported verbatim'); + assert.equal(versionBand.band, `${major}.${minor}`, 'band tracks the current minor line'); + assert.ok(versionBand.summary, 'versionBand has a summary'); + assert.ok(versionBand.policy, 'versionBand has a policy'); + + const recommended = versionBand.supportedRanges.recommended; + assert.equal( + typeof recommended, + 'string', + 'versionBand declares a recommended consumer range', + ); + const bandEsc = versionBand.band.replace('.', '\\.'); + assert.match( + recommended, + new RegExp(`^~${bandEsc}\\.\\d+$`), + 'recommended range stays within the declared band', + ); + assert.ok( + Array.isArray(versionBand.supportedRanges.alsoAccepted) && + versionBand.supportedRanges.alsoAccepted.length > 0, + 'versionBand lists at least one additional accepted range', + ); +}); diff --git a/tests/test_conformance.py b/tests/test_conformance.py new file mode 100644 index 0000000..09886d4 --- /dev/null +++ b/tests/test_conformance.py @@ -0,0 +1,57 @@ +"""pytest entry point for wasmagent-protocol. + +Wraps the canonical conformance harness (``tests/conformance.py``) so the +schema/fixture invariants run under ``pytest tests/``, and asserts the +``@wasmagent/protocol`` package-structure + version-band invariants (issue #96). +Run with: ``pytest tests/ -x -q``. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "tests")) + +import conformance # noqa: E402 (tests/conformance.py — canonical harness) + + +def test_conformance_harness_passes() -> None: + """Every schema is well-formed, $refs resolve, fixtures pass/fail as expected.""" + conformance.errors.clear() + rc = conformance.main() + assert rc == 0, "conformance.py reported failures:\n" + "\n".join(conformance.errors) + + +def test_package_json_declares_version_band_mechanism() -> None: + """package.json must initialize @wasmagent/protocol with a version band.""" + pkg = json.loads((ROOT / "package.json").read_text(encoding="utf-8")) + assert pkg["name"] == "@wasmagent/protocol" + assert isinstance(pkg["version"], str) and pkg["version"].count(".") >= 2 + + # Runtime band: engines.node declares the supported Node line. + assert pkg.get("engines", {}).get("node"), "engines.node runtime band must be declared" + + # Version band: tracks the current minor line and recommends an in-band range. + major, minor, *_ = pkg["version"].split(".") + band = pkg["versionBand"] + assert band["band"] == f"{major}.{minor}", "declared band tracks the version line" + assert band["summary"], "versionBand has a summary" + assert band["policy"], "versionBand has a policy" + recommended = band["supportedRanges"]["recommended"] + assert recommended.startswith("~"), "recommended range pins a single band" + assert recommended.split("~")[1].split(".")[0] == major, "recommended range matches the band major" + also = band["supportedRanges"]["alsoAccepted"] + assert isinstance(also, list) and len(also) >= 1, "at least one additional accepted range" + + +def test_pyproject_declares_python_package_structure() -> None: + """The Python distribution (wasmagent-protocol) mirrors the package metadata + and declares its own runtime band via requires-python.""" + text = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + assert 'name = "wasmagent-protocol"' in text, "Python dist name is declared" + assert "version" in text, "Python dist declares a version" + assert "requires-python" in text, "Python dist declares a runtime band" + assert "Apache-2.0" in text, "Python dist carries the license"