diff --git a/.github/workflows/schema-drift.yml b/.github/workflows/schema-drift.yml new file mode 100644 index 0000000..844bd3d --- /dev/null +++ b/.github/workflows/schema-drift.yml @@ -0,0 +1,73 @@ +name: schema-drift + +# Reusable cross-repository schema-drift gate. Consumer repos call it so that a +# forked or drifted canonical wasmagent.dev schema fails CI automatically: +# +# jobs: +# schema-drift: +# uses: WasmAgent/wasmagent-protocol/.github/workflows/schema-drift.yml@v0.1.6 +# +# The implementation lives only in this repo (single source of truth). It fails +# when the calling repo: +# - vendors a *.schema.json that re-declares a canonical $id but differs from +# the pinned package version, +# - re-declares a canonical $id without depending on @wasmagent/protocol / +# wasmagent-protocol, or +# - ships a competing schemas/index.json listing canonical schema ids. +on: + workflow_call: + inputs: + package-version: + description: 'Pinned @wasmagent/protocol / wasmagent-protocol version to check against (default: latest).' + required: false + type: string + default: '' + python-version: + description: 'Python version for the drift CLI.' + required: false + type: string + default: '3.x' + node-version: + description: 'Node version for the drift CLI.' + required: false + type: string + default: '20' + workflow_dispatch: + +jobs: + drift-python: + name: Drift gate (Python) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + - name: Install the canonical package + drift CLI + run: | + python -m pip install --upgrade pip + if [ -n "${{ inputs.package-version }}" ]; then + python -m pip install "wasmagent-protocol[validate]==${{ inputs.package-version }}" + else + python -m pip install "wasmagent-protocol[validate]" + fi + - name: Detect schema drift + competing registries + run: wasmagent-protocol check --scan --root . + + drift-js: + name: Drift gate (JS) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + - name: Install the canonical package + run: | + if [ -n "${{ inputs.package-version }}" ]; then + npm install --no-save "@wasmagent/protocol@${{ inputs.package-version }}" + else + npm install --no-save "@wasmagent/protocol" + fi + - name: Detect schema drift + competing registries + run: node ./node_modules/@wasmagent/protocol/bin/cli.js check --scan --root . diff --git a/README.md b/README.md index 7e31c83..f5952a2 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,40 @@ aep = get_schema("aep-record") # parsed dict path = schema_path("aep-record") # pathlib.Path to the .json file ``` +## Preventing cross-repo drift + +Downstream repos must not keep local copies of these schemas — but "must not" +is now also **enforced in CI**, not just written down. This repo ships a reusable +drift gate. + +### CLI + +Both packages expose `wasmagent-protocol check`. It fails non-zero when a +vendored schema differs from the canonical version, when a canonical `$id` is +re-declared without depending on the package, or when a competing +`schemas/index.json` is shipped. + +```bash +# compare one vendored file against the pinned canonical version +wasmagent-protocol check path/to/aep-record.schema.json --id aep-record + +# scan a whole repo for drift and competing registries +wasmagent-protocol check --scan --root . +``` + +### Reusable GitHub workflow + +Consumer repos call the shared gate with one job: + +```yaml +jobs: + schema-drift: + uses: WasmAgent/wasmagent-protocol/.github/workflows/schema-drift.yml@v0.1.6 +``` + +A PR in any consumer that forks or drifts a canonical schema now fails CI +automatically. See [`docs/CONTRACT-CHANGE-PROCESS.md`](docs/CONTRACT-CHANGE-PROCESS.md). + ## Versioning & stability - Each schema carries a `version` string (see the registry). @@ -97,8 +131,11 @@ maintainer and exit-condition policy. ```bash # validate every schema is well-formed and every fixture conforms -python3 -m pip install jsonschema +python3 -m pip install -e ".[dev]" python3 tests/conformance.py + +# run the drift gate against this repo (auto-detects the canonical source) +python3 -m wasmagent_protocol check --scan --root . ``` ## Releases @@ -106,6 +143,7 @@ python3 tests/conformance.py Published to npm and PyPI from CI via OIDC trusted publishing on `v*` tags — no tokens stored. See [`docs/CONTRACT-CHANGE-PROCESS.md`](docs/CONTRACT-CHANGE-PROCESS.md). +- **0.1.6** — cross-repo schema-drift gate: `wasmagent-protocol check` CLI (npm + PyPI) and the reusable `.github/workflows/schema-drift.yml` workflow. - **0.1.5** — first successful npm OIDC publish (trusted publisher now registered on npmjs). - **0.1.4** — npm OIDC groundwork; trusted publisher was not yet saved on npmjs. - **0.1.3** — npm OIDC attempt: dropped registry-url (ENEEDAUTH); PyPI only. diff --git a/bin/cli.js b/bin/cli.js new file mode 100755 index 0000000..9baea74 --- /dev/null +++ b/bin/cli.js @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// @wasmagent/protocol — cross-repo schema drift gate CLI. +// +// wasmagent-protocol check path/to/aep-record.schema.json --id aep-record +// wasmagent-protocol check --scan --root . +// +// Exits non-zero on any drift or violation. See index.js for the library API. + +import { resolve } from 'node:path'; +import { + checkFile, + scan, + hasDrift, +} from '../index.js'; + +function format(f) { + const level = f.ok ? 'OK' : 'ERROR'; + return `${level.padEnd(5)} [${f.code}] ${f.path}: ${f.message}`; +} + +function parseArgs(argv) { + const out = { command: null, path: null, schemaId: null, root: '.', scan: false, allowCanonicalSource: false, help: false, version: false }; + const args = argv.slice(2); + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '-h' || a === '--help') out.help = true; + else if (a === '-v' || a === '--version') out.version = true; + else if (a === 'check') out.command = 'check'; + else if (a === '--scan') out.scan = true; + else if (a === '--allow-canonical-source') out.allowCanonicalSource = true; + else if (a === '--id') out.schemaId = args[++i]; + else if (a === '--root') out.root = args[++i]; + else if (a.startsWith('--')) { + console.error(`error: unknown option ${a}`); + process.exit(2); + } else if (out.command === 'check' && out.path === null) out.path = a; + else { + console.error(`error: unexpected argument ${a}`); + process.exit(2); + } + } + return out; +} + +function printHelp() { + console.log(`@wasmagent/protocol — cross-repo schema drift gate + +Usage: + wasmagent-protocol check --id + Compare one vendored *.schema.json to the canonical version. + + wasmagent-protocol check --scan [--root ] [--allow-canonical-source] + Scan a repo for drifted canonical schemas, re-declared canonical $ids + without a package dependency, and competing schemas/index.json registries. + +Exits non-zero on any drift or violation.`); +} + +function main() { + const args = parseArgs(process.argv); + if (args.help || args.command === null) { + printHelp(); + return args.command === null ? 1 : 0; + } + if (args.version) { + console.log('@wasmagent/protocol drift gate'); + return 0; + } + if (args.command !== 'check') { + console.error(`error: unknown command ${args.command}`); + return 2; + } + + if (args.path) { + if (!args.schemaId) { + console.error('error: --id is required when checking an explicit path'); + return 2; + } + const finding = checkFile(args.path, args.schemaId); + console.log(format(finding)); + return finding.ok ? 0 : 1; + } + + const root = resolve(args.root); + const findings = scan(root, { allowCanonicalSource: args.allowCanonicalSource }); + for (const f of findings) console.log(format(f)); + if (hasDrift(findings)) { + const errors = findings.filter((f) => !f.ok); + console.error(`\n${errors.length} drift/violation(s) found under ${root}`); + return 1; + } + const checked = findings.filter((f) => f.code === 'match').length; + console.log(`no drift detected under ${root} (${checked} canonical schema file(s) verified)`); + return 0; +} + +process.exit(main()); diff --git a/docs/CONTRACT-CHANGE-PROCESS.md b/docs/CONTRACT-CHANGE-PROCESS.md index fa28fab..98f451c 100644 --- a/docs/CONTRACT-CHANGE-PROCESS.md +++ b/docs/CONTRACT-CHANGE-PROCESS.md @@ -55,3 +55,42 @@ If unsure, treat it as breaking. **No consumer repo keeps a local copy of a schema in this repo.** If you find a copied schema JSON in a consumer repo, that is a bug: delete it and depend on the package. Drift between copies is exactly the failure this repo prevents. + +## Enforcing it in CI: the schema-drift gate + +The one rule is now machine-enforced, not just documented. This repo ships a +reusable drift gate that any consumer can wire into CI so a forked schema fails +to merge automatically. + +- **CLI** — `@wasmagent/protocol` (npm) and `wasmagent-protocol` (PyPI) both + expose `wasmagent-protocol check`: + + ```bash + # compare one vendored file against the pinned canonical version + wasmagent-protocol check path/to/aep-record.schema.json --id aep-record + + # scan the whole repo for drift, re-declared canonical $ids with no package + # dependency, and competing schemas/index.json registries + wasmagent-protocol check --scan --root . + ``` + + It exits non-zero on any drift or violation, so it drops straight into CI. + `wasmagent-protocol` auto-detects when it is running inside this repo (the + canonical source) and relaxes the source-only checks there. + +- **Reusable workflow** — `.github/workflows/schema-drift.yml` is a + `workflow_call` gate consumer repos invoke with one line: + + ```yaml + jobs: + schema-drift: + uses: WasmAgent/wasmagent-protocol/.github/workflows/schema-drift.yml@v0.1.6 + ``` + + It installs the pinned package and runs the scan. A PR that forks or drifts a + canonical schema, re-declares a canonical `$id` without depending on the + package, or ships a competing `schemas/index.json` fails CI. + +Consumer repos that vendor a canonical schema should instead delete the copy and +`npm install @wasmagent/protocol` / `pip install wasmagent-protocol`. See +[README.md](../README.md) for consumption. diff --git a/index.d.ts b/index.d.ts index 34c4dde..465117e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -30,3 +30,42 @@ export const schemas: Record; * (e.g. "aep-record", "constraint-ir"). Throws on unknown id. */ export function getSchema(id: string): unknown; + +// --------------------------------------------------------------------------- +// Cross-repo schema drift detection. +// --------------------------------------------------------------------------- + +/** Canonical host prefix for every registered schema $id. */ +export const canonicalHost: string; + +/** A single drift-scan result. `ok === false` blocks CI. */ +export interface DriftFinding { + ok: boolean; + code: string; + path: string; + message: string; +} + +/** Canonical, order-independent serialization of a schema document. */ +export function normalizeSchema(input: string | unknown): string; + +/** The set of canonical $id URIs for every registered schema. */ +export function canonicalIds(): Set; + +/** Map a canonical $id back to its registry id, or null. */ +export function schemaIdForCanonical(canonicalId: string): string | null; + +/** Compare a vendored schema file to the canonical schema for `schemaId`. */ +export function checkFile(filePath: string, schemaId: string): DriftFinding; + +/** True if `root` is the wasmagent-protocol repo itself (best-effort). */ +export function isCanonicalSource(root: string): boolean; + +/** True if `root` declares a dependency on the protocol package (best-effort). */ +export function dependsOnPackage(root: string): boolean; + +/** Scan `root` for drift, re-declared canonical ids, and competing registries. */ +export function scan(root: string, options?: { allowCanonicalSource?: boolean }): DriftFinding[]; + +/** True if any finding in the list is an error. */ +export function hasDrift(findings: DriftFinding[]): boolean; diff --git a/index.js b/index.js index dc7a00a..ed04f0d 100644 --- a/index.js +++ b/index.js @@ -2,7 +2,7 @@ // Single source of truth across the WasmAgent org. Do not copy these schemas // into consumer repositories; depend on this package instead. -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -30,3 +30,245 @@ 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)])); + +// --------------------------------------------------------------------------- +// Cross-repo schema drift detection. +// +// A vendored copy of a canonical wasmagent.dev schema is "drifted" when its +// normalized JSON differs from the canonical schema shipped by this package. +// The `check` CLI and the shared schema-drift.yml workflow build on these. +// --------------------------------------------------------------------------- + +/** Canonical host prefix for every registered schema $id. */ +export const canonicalHost = index.canonical_host ?? 'https://wasmagent.dev/schemas/'; + +const PACKAGE_NAMES = new Set(['@wasmagent/protocol', 'wasmagent-protocol']); + +const SCAN_IGNORE_DIRS = new Set([ + '.git', + 'node_modules', + '.venv', + 'venv', + 'env', + 'dist', + 'build', + '__pycache__', + '.pytest_cache', + '.claude-bot', + '.turbo', + '.next', + 'target', + 'tests', + '.idea', + '.vscode', +]); + +function sortKeys(value) { + if (Array.isArray(value)) return value.map(sortKeys); + if (value && typeof value === 'object') { + return Object.keys(value) + .sort() + .reduce((acc, k) => { + acc[k] = sortKeys(value[k]); + return acc; + }, {}); + } + return value; +} + +/** + * Canonical, order-independent serialization of a schema document. Accepts a + * raw JSON string (a vendored file) or an already-parsed object (the canonical + * schema from getSchema). Two schemas that differ only in key order or + * whitespace compare equal. + */ +export function normalizeSchema(input) { + const obj = typeof input === 'string' ? JSON.parse(input) : input; + return JSON.stringify(sortKeys(obj)); +} + +/** The set of canonical $id URIs for every registered schema. */ +export function canonicalIds() { + return new Set(index.schemas.map((s) => s.canonical_id)); +} + +/** Map a canonical $id back to its registry id, or null. */ +export function schemaIdForCanonical(canonicalId) { + const entry = index.schemas.find((s) => s.canonical_id === canonicalId); + return entry ? entry.id : null; +} + +/** + * Compare a single vendored schema file to the canonical schema for `schemaId`. + * Returns a finding object: { ok, code, path, message }. + */ +export function checkFile(filePath, schemaId) { + const entry = byId.get(schemaId); + if (!entry) { + return { + ok: false, + code: 'unknown-id', + path: filePath, + message: `unknown schema id ${JSON.stringify(schemaId)}`, + }; + } + const text = readFileSync(filePath, 'utf8'); + const canonical = getSchema(schemaId); + if (normalizeSchema(text) === normalizeSchema(canonical)) { + return { ok: true, code: 'match', path: filePath, message: `${schemaId}: matches canonical` }; + } + return { + ok: false, + code: 'drift', + path: filePath, + message: `${schemaId}: vendored schema differs from canonical`, + }; +} + +function iterFiles(root, suffix) { + const out = []; + function walk(dir) { + let entries; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const name of entries) { + const full = join(dir, name); + if (SCAN_IGNORE_DIRS.has(name)) continue; + let st; + try { + st = statSync(full); + } catch { + continue; + } + if (st.isDirectory()) walk(full); + else if (name.endsWith(suffix)) out.push(full); + } + } + walk(root); + return out; +} + +/** True if `root` is the wasmagent-protocol repo itself (best-effort). */ +export function isCanonicalSource(root) { + try { + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + if (PACKAGE_NAMES.has(pkg.name)) return true; + } catch { + // ignore + } + return false; +} + +/** True if `root` declares a dependency on the protocol package (best-effort). */ +export function dependsOnPackage(root) { + let pkg = {}; + try { + pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + } catch { + pkg = {}; + } + for (const key of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { + const deps = pkg[key]; + if (deps && typeof deps === 'object') { + for (const name of Object.keys(deps)) { + if (PACKAGE_NAMES.has(name)) return true; + } + } + } + return false; +} + +/** + * Scan `root` for vendored canonical-schema drift, re-declared canonical ids + * without a package dependency, and competing schemas/index.json registries. + * Returns a list of finding objects: { ok, code, path, message }. + */ +export function scan(root, { allowCanonicalSource = false } = {}) { + const treatAsSource = isCanonicalSource(root) || allowCanonicalSource; + const hasDep = dependsOnPackage(root); + const canonicalById = new Map(index.schemas.map((s) => [s.canonical_id, s])); + const findings = []; + + for (const filePath of iterFiles(root, '.schema.json')) { + let doc; + try { + doc = JSON.parse(readFileSync(filePath, 'utf8')); + } catch (err) { + findings.push({ ok: false, code: 'invalid-json', path: filePath, message: `cannot parse JSON: ${err.message}` }); + continue; + } + const sid = doc && typeof doc === 'object' ? doc.$id : null; + if (typeof sid !== 'string' || !sid.startsWith(canonicalHost)) continue; + const entry = canonicalById.get(sid); + if (!entry) { + findings.push({ + ok: false, + code: 'unknown-canonical-id', + path: filePath, + message: `$id ${sid} is under the canonical host but is not registered`, + }); + continue; + } + if (normalizeSchema(doc) !== normalizeSchema(getSchema(entry.id))) { + findings.push({ + ok: false, + code: 'drift', + path: filePath, + message: `${entry.id}: vendored schema differs from canonical`, + }); + continue; + } + if (treatAsSource) { + findings.push({ ok: true, code: 'match', path: filePath, message: `${entry.id}: matches canonical (source repo)` }); + } else if (hasDep) { + findings.push({ ok: true, code: 'match', path: filePath, message: `${entry.id}: matches canonical` }); + } else { + findings.push({ + ok: false, + code: 'no-package-dep', + path: filePath, + message: `${entry.id}: re-declares canonical $id but the repo does not depend on @wasmagent/protocol / wasmagent-protocol`, + }); + } + } + + // Registry guard: only wasmagent-protocol may ship a canonical registry. + if (!treatAsSource) { + for (const idxPath of iterFiles(root, '.json')) { + const base = idxPath.split(/[\\/]/).pop(); + if (base !== 'index.json') continue; + let idx; + try { + idx = JSON.parse(readFileSync(idxPath, 'utf8')); + } catch { + continue; + } + const entries = idx && Array.isArray(idx.schemas) ? idx.schemas : null; + if (!entries) continue; + for (const entry of entries) { + if (!entry || typeof entry !== 'object') continue; + const cid = entry.canonical_id; + if (typeof cid === 'string' && cid.startsWith(canonicalHost)) { + findings.push({ + ok: false, + code: 'competing-registry', + path: idxPath, + message: `lists canonical schema id ${cid} — only the wasmagent-protocol repo may ship a canonical registry`, + }); + break; + } + } + } + } + + return findings; +} + +/** True if any finding in the list is an error. */ +export function hasDrift(findings) { + return findings.some((f) => !f.ok); +} + diff --git a/package.json b/package.json index 8bd4f37..02437f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wasmagent/protocol", - "version": "0.1.5", + "version": "0.1.6", "description": "Canonical JSON Schemas for the WasmAgent Agent Evidence Protocol (AEP) and compliance contracts. Single source of truth across the WasmAgent org.", "keywords": ["wasmagent", "aep", "agent-evidence-protocol", "json-schema", "compliance", "provenance"], "homepage": "https://github.com/WasmAgent/wasmagent-protocol#readme", @@ -22,13 +22,18 @@ }, "./schemas/*": "./schemas/*" }, + "bin": { + "wasmagent-protocol": "./bin/cli.js" + }, "files": [ "index.js", "index.d.ts", + "bin/", "schemas/" ], "scripts": { - "test": "node --test" + "test": "node --test", + "drift": "node ./bin/cli.js check --scan --root ." }, "publishConfig": { "access": "public" diff --git a/pyproject.toml b/pyproject.toml index 110041a..5e40cbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "wasmagent-protocol" -version = "0.1.5" +version = "0.1.6" description = "Canonical JSON Schemas for the WasmAgent Agent Evidence Protocol (AEP) and compliance contracts. Single source of truth across the WasmAgent org." readme = "README.md" requires-python = ">=3.9" @@ -21,8 +21,16 @@ classifiers = [ ] dependencies = [] +[project.scripts] +wasmagent-protocol = "wasmagent_protocol.cli:main" + [project.optional-dependencies] validate = ["jsonschema>=4.0"] +dev = [ + "jsonschema>=4.0", + "referencing>=0.20", + "pytest>=7", +] [project.urls] Homepage = "https://github.com/WasmAgent/wasmagent-protocol" diff --git a/src/wasmagent_protocol/__init__.py b/src/wasmagent_protocol/__init__.py index c67435a..72fd3df 100644 --- a/src/wasmagent_protocol/__init__.py +++ b/src/wasmagent_protocol/__init__.py @@ -20,14 +20,43 @@ __all__ = ["INDEX", "get_schema", "schema_path", "schema_ids"] _SCHEMAS_PKG = "wasmagent_protocol.schemas" +# Editable / source-checkout fallback: the canonical schemas live at the repo +# root under schemas/, which is two directories above this file +# (src/wasmagent_protocol/__init__.py -> /schemas). Built wheels ship the +# same tree inside the package via force-include, so the resource API is tried +# first; this Path is the fallback for `pip install -e .` where force-include is +# not materialized. +_SOURCE_SCHEMAS = Path(__file__).resolve().parents[2] / "schemas" + + +def _schemas_root(): + """Locate the canonical schemas directory as a Traversable. + + Installed wheels package schemas under ``wasmagent_protocol.schemas`` + (force-include in pyproject). Editable installs do not materialize that + mapping, so fall back to the repo-root ``schemas/`` directory. + """ + try: + root = resources.files(_SCHEMAS_PKG) + if root.joinpath("index.json").is_file(): + return root + except Exception: # pragma: no cover - depends on install layout + pass + if (_SOURCE_SCHEMAS / "index.json").is_file(): + return _SOURCE_SCHEMAS + raise FileNotFoundError( + "wasmagent_protocol schemas not found: neither the packaged " + "wasmagent_protocol.schemas resource nor the source checkout at " + f"{_SOURCE_SCHEMAS} contains index.json" + ) def _read(rel_path: str) -> str: # rel_path is e.g. "index.json" or "aep/aep-record.schema.json" - resource = resources.files(_SCHEMAS_PKG) + node = _schemas_root() for part in rel_path.split("/"): - resource = resource / part - return resource.read_text(encoding="utf-8") + node = node.joinpath(part) + return node.read_text(encoding="utf-8") INDEX: dict[str, Any] = json.loads(_read("index.json")) @@ -69,8 +98,10 @@ def schema_path(schema_id: str) -> Path: raise KeyError( f"unknown schema id {schema_id!r}; known: {', '.join(_PATHS)}" ) from None - resource = resources.files(_SCHEMAS_PKG) + node = _schemas_root() for part in rel.split("/"): - resource = resource / part - with resources.as_file(resource) as p: + node = node.joinpath(part) + if isinstance(node, Path): + return node + with resources.as_file(node) as p: return Path(p) diff --git a/src/wasmagent_protocol/__main__.py b/src/wasmagent_protocol/__main__.py new file mode 100644 index 0000000..d3589a4 --- /dev/null +++ b/src/wasmagent_protocol/__main__.py @@ -0,0 +1,6 @@ +"""Enable ``python -m wasmagent_protocol`` as an alias for the drift CLI.""" + +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/wasmagent_protocol/cli.py b/src/wasmagent_protocol/cli.py new file mode 100644 index 0000000..7134729 --- /dev/null +++ b/src/wasmagent_protocol/cli.py @@ -0,0 +1,116 @@ +"""``wasmagent-protocol`` command-line entry point. + +Usage:: + + # Compare one vendored schema file against the canonical version. + wasmagent-protocol check path/to/aep-record.schema.json --id aep-record + + # Scan a whole repo for drift, re-declared canonical ids without a package + # dependency, and competing schemas/index.json registries. + wasmagent-protocol check --scan --root . + +Exits non-zero on any drift or violation, so this is safe to wire into CI. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from . import drift + + +def _format(finding: drift.Finding) -> str: + return f"{finding.severity.upper():5} [{finding.code}] {finding.path}: {finding.message}" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="wasmagent-protocol", + description=( + "Cross-repo schema drift gate for the WasmAgent protocol family. " + "Fails if a vendored schema differs from the canonical version." + ), + ) + parser.add_argument("--version", action="version", version="wasmagent-protocol drift gate") + sub = parser.add_subparsers(dest="command", required=True) + + check = sub.add_parser( + "check", + help="Detect schema drift against the canonical package schemas.", + description=( + "With an explicit PATH, compare that one file to the canonical " + "schema for --id. Without PATH (or with --scan), walk the repo " + "root and fail on any drifted or competing canonical schema." + ), + ) + check.add_argument( + "path", + nargs="?", + help="Path to a vendored *.schema.json file. Omit to scan the repo root.", + ) + check.add_argument( + "--id", + dest="schema_id", + help="Canonical schema id (e.g. aep-record). Required with an explicit PATH.", + ) + check.add_argument("--root", default=".", help="Repo root to scan (default: current directory).") + check.add_argument( + "--scan", + action="store_true", + help="Scan the repo root for vendored canonical schemas and competing registries.", + ) + check.add_argument( + "--allow-canonical-source", + action="store_true", + help=( + "Treat the repo as the canonical wasmagent-protocol source (skips the " + "no-package-dep and competing-registry checks). Auto-detected normally." + ), + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + + if args.command != "check": + return 2 + + if args.path: + if not args.schema_id: + print("error: --id is required when checking an explicit path", file=sys.stderr) + return 2 + try: + finding = drift.check_file(args.path, args.schema_id) + except FileNotFoundError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + except KeyError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + print(_format(finding)) + return 1 if finding.is_error else 0 + + root = Path(args.root).resolve() + findings = drift.scan(root, allow_canonical_source=args.allow_canonical_source) + for finding in findings: + print(_format(finding)) + + errors = [f for f in findings if f.is_error] + if errors: + print( + f"\n{len(errors)} drift/violation(s) found under {root}", + file=sys.stderr, + ) + return 1 + checked = [f for f in findings if f.code == "match"] + print( + f"no drift detected under {root} ({len(checked)} canonical schema file(s) verified)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/wasmagent_protocol/drift.py b/src/wasmagent_protocol/drift.py new file mode 100644 index 0000000..c1ae558 --- /dev/null +++ b/src/wasmagent_protocol/drift.py @@ -0,0 +1,283 @@ +"""Cross-repository schema drift detection. + +A vendored copy of a canonical ``wasmagent.dev`` schema is *drifted* when its +normalized JSON differs from the canonical schema shipped by this package at the +pinned version. This module implements the comparison and a repo-wide scan that +the published ``wasmagent-protocol check`` CLI and the shared +``schema-drift.yml`` workflow both build on. + +Three failure modes are detected: + +- ``drift`` — a ``*.schema.json`` re-declares a canonical ``$id`` but its + content differs from the canonical schema. +- ``no-package-dep`` — a file re-declares a canonical ``$id`` while the repo + does not depend on ``@wasmagent/protocol`` / ``wasmagent-protocol`` (a fork + with no tracking). +- ``competing-registry`` — a repo other than ``wasmagent-protocol`` ships a + ``schemas/index.json`` listing canonical schema ids. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from . import INDEX, get_schema + +CANONICAL_HOST = INDEX.get("canonical_host", "https://wasmagent.dev/schemas/") + +_PACKAGE_NAMES = {"@wasmagent/protocol", "wasmagent-protocol"} + +# Directories never worth scanning for vendored schemas or competing registries. +_SCAN_IGNORE_DIRS = { + ".git", + "node_modules", + ".venv", + "venv", + "env", + "dist", + "build", + "__pycache__", + ".pytest_cache", + ".claude-bot", + ".turbo", + ".next", + "target", + "tests", + ".idea", + ".vscode", +} + + +def normalize_schema(obj: Any) -> str: + """Canonical, order-independent serialization of a schema document. + + Accepts either a raw JSON string (a vendored file) or an already-parsed + object (the canonical schema returned by ``get_schema``). Two schemas that + differ only in key order or whitespace compare equal. + """ + if isinstance(obj, str): + obj = json.loads(obj) + return json.dumps(_sort_keys(obj), separators=(",", ":")) + + +def _sort_keys(value: Any) -> Any: + if isinstance(value, dict): + return {k: _sort_keys(value[k]) for k in sorted(value)} + if isinstance(value, list): + return [_sort_keys(v) for v in value] + return value + + +def canonical_ids() -> set[str]: + """Return the set of canonical ``$id`` URIs for every registered schema.""" + return {entry["canonical_id"] for entry in INDEX["schemas"]} + + +def schema_id_for_canonical(canonical_id: str) -> str | None: + """Map a canonical ``$id`` back to its registry id, or ``None``.""" + for entry in INDEX["schemas"]: + if entry["canonical_id"] == canonical_id: + return entry["id"] + return None + + +@dataclass +class Finding: + """A single drift-scan result. + + ``severity`` is ``"error"`` (blocks CI), ``"ok"`` (canonical match), or + ``"skip"`` (not a canonical schema / not applicable). + """ + + severity: str + code: str + path: str + message: str + + @property + def is_error(self) -> bool: + return self.severity == "error" + + +def _ok(code: str, path: Path, message: str) -> Finding: + return Finding("ok", code, str(path), message) + + +def _error(code: str, path: Path, message: str) -> Finding: + return Finding("error", code, str(path), message) + + +def check_file(path: str | Path, schema_id: str) -> Finding: + """Compare a single vendored schema file to the canonical ``schema_id``. + + Raises ``FileNotFoundError`` if ``path`` does not exist and ``KeyError`` if + ``schema_id`` is not a registered canonical id. + """ + p = Path(path) + text = p.read_text(encoding="utf-8") + canonical = get_schema(schema_id) # raises KeyError on unknown id + if normalize_schema(text) == normalize_schema(canonical): + return _ok("match", p, f"{schema_id}: matches canonical") + return _error( + "drift", + p, + f"{schema_id}: vendored schema differs from canonical " + f"({INDEX.get('protocol', 'AEP')} package)", + ) + + +def _iter_files(root: Path, suffix: str): + for path in root.rglob(f"*{suffix}"): + if any(part in _SCAN_IGNORE_DIRS for part in path.parts): + continue + yield path + + +def is_canonical_source(root: str | Path) -> bool: + """True if ``root`` is the ``wasmagent-protocol`` repo itself.""" + root = Path(root) + pkg = root / "package.json" + if pkg.is_file(): + try: + if json.loads(pkg.read_text(encoding="utf-8")).get("name") in _PACKAGE_NAMES: + return True + except (json.JSONDecodeError, OSError): + pass + pyproj = root / "pyproject.toml" + if pyproj.is_file(): + text = pyproj.read_text(encoding="utf-8") + if 'name = "wasmagent-protocol"' in text or "name = 'wasmagent-protocol'" in text: + return True + return False + + +def depends_on_package(root: str | Path) -> bool: + """True if ``root`` declares a dependency on the protocol package. + + Best-effort heuristic over npm ``package.json`` dependency maps and Python + ``requirements*.txt`` / ``pyproject.toml``. + """ + root = Path(root) + pkg = root / "package.json" + if pkg.is_file(): + try: + data = json.loads(pkg.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + data = {} + for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"): + deps = data.get(key) + if isinstance(deps, dict) and any(name in _PACKAGE_NAMES for name in deps): + return True + for req_name in ("requirements.txt", "requirements-dev.txt", "requirements.txt.in"): + req = root / req_name + if req.is_file() and "wasmagent-protocol" in req.read_text(encoding="utf-8"): + return True + pyproj = root / "pyproject.toml" + if pyproj.is_file(): + # Only count it as a dependency if the project is NOT the package itself. + text = pyproj.read_text(encoding="utf-8") + if ( + 'name = "wasmagent-protocol"' not in text + and "name = 'wasmagent-protocol'" not in text + and "wasmagent-protocol" in text + ): + return True + return False + + +def scan( + root: str | Path, + *, + allow_canonical_source: bool = False, +) -> list[Finding]: + """Scan ``root`` for vendored canonical-schema drift and competing registries. + + When ``root`` is the ``wasmagent-protocol`` repo (auto-detected, or forced + via ``allow_canonical_source``), the no-package-dep and competing-registry + checks are skipped — this repo is the legitimate source of both. + """ + root = Path(root).resolve() + treat_as_source = is_canonical_source(root) or allow_canonical_source + has_dep = depends_on_package(root) + + findings: list[Finding] = [] + canonical_by_id = {entry["canonical_id"]: entry for entry in INDEX["schemas"]} + + for path in _iter_files(root, ".schema.json"): + try: + doc = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + findings.append(_error("invalid-json", path, f"cannot parse JSON: {exc}")) + continue + sid = doc.get("$id") if isinstance(doc, dict) else None + if not isinstance(sid, str) or not sid.startswith(CANONICAL_HOST): + continue # not a canonical schema — leave it alone + entry = canonical_by_id.get(sid) + if entry is None: + findings.append( + _error( + "unknown-canonical-id", + path, + f"$id {sid} is under the canonical host but is not registered", + ) + ) + continue + if normalize_schema(doc) != normalize_schema(get_schema(entry["id"])): + findings.append( + _error( + "drift", + path, + f"{entry['id']}: vendored schema differs from canonical", + ) + ) + continue + # Content matches the canonical schema. + if treat_as_source: + findings.append(_ok("match", path, f"{entry['id']}: matches canonical (source repo)")) + elif has_dep: + findings.append(_ok("match", path, f"{entry['id']}: matches canonical")) + else: + findings.append( + _error( + "no-package-dep", + path, + f"{entry['id']}: re-declares canonical $id but the repo does not " + "depend on @wasmagent/protocol / wasmagent-protocol", + ) + ) + + # Registry guard: only wasmagent-protocol may ship a canonical registry. + if not treat_as_source: + for idx_path in root.rglob("index.json"): + if any(part in _SCAN_IGNORE_DIRS for part in idx_path.parts): + continue + try: + idx = json.loads(idx_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + continue + entries = idx.get("schemas") if isinstance(idx, dict) else None + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, dict): + continue + cid = entry.get("canonical_id") + if isinstance(cid, str) and cid.startswith(CANONICAL_HOST): + findings.append( + _error( + "competing-registry", + idx_path, + f"lists canonical schema id {cid} — only the " + "wasmagent-protocol repo may ship a canonical registry", + ) + ) + break + + return findings + + +def has_drift(findings: list[Finding]) -> bool: + return any(f.is_error for f in findings) diff --git a/tests/drift.test.js b/tests/drift.test.js new file mode 100644 index 0000000..478d013 --- /dev/null +++ b/tests/drift.test.js @@ -0,0 +1,200 @@ +// Tests for the cross-repo schema drift gate (index.js helpers + bin CLI). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + getSchema, + normalizeSchema, + canonicalIds, + schemaIdForCanonical, + checkFile, + scan, + hasDrift, + isCanonicalSource, + dependsOnPackage, + canonicalHost, +} from '../index.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(here, '..'); +const BIN = join(REPO_ROOT, 'bin', 'cli.js'); +const AEP_CANONICAL_ID = 'https://wasmagent.dev/schemas/aep/aep-record.schema.json'; + +function mkTree() { + const dir = mkdtempSync(join(tmpdir(), 'wp-drift-')); + return dir; +} + +function writeJSON(file, obj) { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(obj), 'utf8'); + return file; +} + +function vendorAep(dir, obj, { dep = false, name = 'consumer-repo' } = {}) { + writeJSON(join(dir, 'package.json'), dep + ? { name, dependencies: { '@wasmagent/protocol': '0.1.6' } } + : { name }); + return writeJSON(join(dir, 'schemas', 'aep', 'aep-record.schema.json'), obj); +} + +// --- normalization -------------------------------------------------------- + +test('normalizeSchema is key-order independent', () => { + assert.equal(normalizeSchema('{"b":1,"a":2}'), normalizeSchema('{"a":2,"b":1}')); +}); + +test('canonicalIds includes registered schemas', () => { + const ids = canonicalIds(); + assert.ok(ids.has(AEP_CANONICAL_ID)); + assert.ok(ids.size >= 1); +}); + +test('schemaIdForCanonical round-trips', () => { + assert.equal(schemaIdForCanonical(AEP_CANONICAL_ID), 'aep-record'); + assert.equal(schemaIdForCanonical('https://nope.invalid/x'), null); +}); + +// --- checkFile ------------------------------------------------------------ + +test('checkFile detects a match', () => { + const dir = mkTree(); + try { + const f = writeJSON(join(dir, 'aep-record.schema.json'), getSchema('aep-record')); + const finding = checkFile(f, 'aep-record'); + assert.equal(finding.ok, true); + assert.equal(finding.code, 'match'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('checkFile detects drift', () => { + const dir = mkTree(); + try { + const drifted = { ...getSchema('aep-record'), title: 'DRIFTED-FORK' }; + const f = writeJSON(join(dir, 'aep-record.schema.json'), drifted); + const finding = checkFile(f, 'aep-record'); + assert.equal(finding.ok, false); + assert.equal(finding.code, 'drift'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// --- scan ----------------------------------------------------------------- + +test('scan is clean for a consumer that depends and matches', () => { + const dir = mkTree(); + try { + vendorAep(dir, getSchema('aep-record'), { dep: true }); + const findings = scan(dir); + assert.equal(hasDrift(findings), false); + assert.ok(findings.some((f) => f.code === 'match')); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('scan flags drift', () => { + const dir = mkTree(); + try { + vendorAep(dir, { ...getSchema('aep-record'), description: 'forked' }, { dep: true }); + const findings = scan(dir); + const codes = findings.filter((f) => !f.ok).map((f) => f.code); + assert.ok(codes.includes('drift')); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('scan flags a re-declared canonical id without a package dep', () => { + const dir = mkTree(); + try { + vendorAep(dir, getSchema('aep-record'), { dep: false }); + const findings = scan(dir); + const codes = findings.filter((f) => !f.ok).map((f) => f.code); + assert.ok(codes.includes('no-package-dep')); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('scan flags a competing registry', () => { + const dir = mkTree(); + try { + vendorAep(dir, getSchema('aep-record'), { dep: true }); + writeJSON(join(dir, 'schemas', 'index.json'), { + schemas: [{ id: 'aep-record', canonical_id: AEP_CANONICAL_ID }], + }); + const findings = scan(dir); + const codes = findings.filter((f) => !f.ok).map((f) => f.code); + assert.ok(codes.includes('competing-registry')); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('scan exempts the canonical source repo', () => { + const dir = mkTree(); + try { + writeJSON(join(dir, 'package.json'), { name: '@wasmagent/protocol' }); + writeJSON(join(dir, 'schemas', 'aep', 'aep-record.schema.json'), getSchema('aep-record')); + writeJSON(join(dir, 'schemas', 'index.json'), { + schemas: [{ id: 'aep-record', canonical_id: AEP_CANONICAL_ID }], + }); + assert.equal(isCanonicalSource(dir), true); + const findings = scan(dir); + assert.equal(hasDrift(findings), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('scan ignores non-canonical schemas', () => { + const dir = mkTree(); + try { + writeJSON(join(dir, 'package.json'), { name: 'consumer-repo' }); + writeJSON(join(dir, 'schemas', 'private.schema.json'), { + $id: 'https://example.invalid/private.schema.json', + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + }); + const findings = scan(dir); + assert.equal(hasDrift(findings), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// --- bin CLI integration -------------------------------------------------- + +function runBin(args) { + return spawnSync(process.execPath, [BIN, ...args], { encoding: 'utf8' }); +} + +test('bin: self-scan of the canonical repo is green', () => { + const res = runBin(['check', '--scan', '--root', REPO_ROOT]); + assert.equal(res.status, 0, `stderr: ${res.stderr}`); +}); + +test('bin: explicit match exits 0', () => { + const res = runBin(['check', join(REPO_ROOT, 'schemas', 'aep', 'aep-record.schema.json'), '--id', 'aep-record']); + assert.equal(res.status, 0, `stderr: ${res.stderr}`); +}); + +test('bin: explicit drift exits 1', () => { + const dir = mkTree(); + try { + const f = writeJSON(join(dir, 'aep-record.schema.json'), { ...getSchema('aep-record'), title: 'DRIFTED' }); + const res = runBin(['check', f, '--id', 'aep-record']); + assert.equal(res.status, 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/test_drift.py b/tests/test_drift.py new file mode 100644 index 0000000..055662c --- /dev/null +++ b/tests/test_drift.py @@ -0,0 +1,230 @@ +"""Tests for the cross-repo schema drift gate (wasmagent_protocol.drift + cli). + +These build synthetic consumer checkouts under tmp_path and assert each failure +mode the gate must catch: drift, no-package-dep, and competing-registry. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from wasmagent_protocol import get_schema +from wasmagent_protocol import cli, drift + +AEP_CANONICAL_ID = "https://wasmagent.dev/schemas/aep/aep-record.schema.json" +REPO_ROOT = Path(__file__).resolve().parents[1] + + +# --- normalization --------------------------------------------------------- + +def test_normalize_schema_is_key_order_independent(): + a = drift.normalize_schema('{"b": 1, "a": 2}') + b = drift.normalize_schema('{"a": 2, "b": 1}') + assert a == b + + +def test_canonical_ids_includes_registered_schemas(): + ids = drift.canonical_ids() + assert AEP_CANONICAL_ID in ids + assert len(ids) >= 1 + + +def test_schema_id_for_canonical_round_trip(): + assert drift.schema_id_for_canonical(AEP_CANONICAL_ID) == "aep-record" + assert drift.schema_id_for_canonical("https://nope.invalid/x") is None + + +# --- check_file ------------------------------------------------------------ + +def _write(path: Path, obj: dict) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(obj), encoding="utf-8") + return path + + +def test_check_file_match(tmp_path): + f = _write(tmp_path / "aep-record.schema.json", get_schema("aep-record")) + finding = drift.check_file(f, "aep-record") + assert finding.is_error is False + assert finding.code == "match" + + +def test_check_file_drift(tmp_path): + drifted = dict(get_schema("aep-record")) + drifted["title"] = "DRIFTED-FORK" + f = _write(tmp_path / "aep-record.schema.json", drifted) + finding = drift.check_file(f, "aep-record") + assert finding.is_error + assert finding.code == "drift" + + +def test_check_file_unknown_id(tmp_path): + f = _write(tmp_path / "aep-record.schema.json", get_schema("aep-record")) + with pytest.raises(KeyError): + drift.check_file(f, "no-such-id") + + +# --- scan ------------------------------------------------------------------ + +def _consumer_with_vendored(tmp_path, schema_obj, *, dep=False, name="consumer-repo"): + """Build a minimal consumer checkout with one vendored aep-record schema.""" + if dep: + _write(tmp_path / "package.json", { + "name": name, + "dependencies": {"@wasmagent/protocol": "0.1.6"}, + }) + else: + _write(tmp_path / "package.json", {"name": name}) + return _write(tmp_path / "schemas" / "aep" / "aep-record.schema.json", schema_obj) + + +def test_scan_clean_consumer_with_dep(tmp_path): + _consumer_with_vendored(tmp_path, get_schema("aep-record"), dep=True) + findings = drift.scan(tmp_path) + assert not drift.has_drift(findings) + assert any(f.code == "match" for f in findings) + + +def test_scan_flags_drift(tmp_path): + drifted = dict(get_schema("aep-record")) + drifted["description"] = "a forked description" + _consumer_with_vendored(tmp_path, drifted, dep=True) + findings = drift.scan(tmp_path) + codes = {f.code for f in findings if f.is_error} + assert "drift" in codes + + +def test_scan_flags_redeclared_id_without_dep(tmp_path): + # Content matches canonical, but the repo does not depend on the package. + _consumer_with_vendored(tmp_path, get_schema("aep-record"), dep=False) + findings = drift.scan(tmp_path) + codes = {f.code for f in findings if f.is_error} + assert "no-package-dep" in codes + + +def test_scan_flags_competing_registry(tmp_path): + _consumer_with_vendored(tmp_path, get_schema("aep-record"), dep=True) + _write(tmp_path / "schemas" / "index.json", { + "schemas": [{"id": "aep-record", "canonical_id": AEP_CANONICAL_ID}], + }) + findings = drift.scan(tmp_path) + codes = {f.code for f in findings if f.is_error} + assert "competing-registry" in codes + + +def test_scan_canonical_source_is_exempt(tmp_path): + # The wasmagent-protocol repo may legitimately ship a canonical registry. + _write(tmp_path / "package.json", {"name": "@wasmagent/protocol"}) + _write(tmp_path / "schemas" / "aep" / "aep-record.schema.json", get_schema("aep-record")) + _write(tmp_path / "schemas" / "index.json", { + "schemas": [{"id": "aep-record", "canonical_id": AEP_CANONICAL_ID}], + }) + findings = drift.scan(tmp_path) + assert not drift.has_drift(findings) + + +def test_scan_ignores_non_canonical_schemas(tmp_path): + _write(tmp_path / "package.json", {"name": "consumer-repo"}) + # A repo-private schema with a non-canonical $id must be left alone. + _write(tmp_path / "schemas" / "private.schema.json", { + "$id": "https://example.invalid/private.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + }) + findings = drift.scan(tmp_path) + assert not drift.has_drift(findings) + + +# --- cli ------------------------------------------------------------------- + +def test_cli_explicit_match_returns_zero(tmp_path, capsys): + f = _write(tmp_path / "aep-record.schema.json", get_schema("aep-record")) + assert cli.main(["check", str(f), "--id", "aep-record"]) == 0 + + +def test_cli_explicit_drift_returns_nonzero(tmp_path, capsys): + drifted = dict(get_schema("aep-record")) + drifted["title"] = "DRIFTED" + f = _write(tmp_path / "aep-record.schema.json", drifted) + assert cli.main(["check", str(f), "--id", "aep-record"]) == 1 + + +def test_cli_scan_clean_returns_zero(tmp_path, capsys): + _consumer_with_vendored(tmp_path, get_schema("aep-record"), dep=True) + assert cli.main(["check", "--scan", "--root", str(tmp_path)]) == 0 + + +def test_cli_scan_drift_returns_nonzero(tmp_path, capsys): + drifted = dict(get_schema("aep-record")) + drifted["title"] = "DRIFTED" + _consumer_with_vendored(tmp_path, drifted, dep=True) + assert cli.main(["check", "--scan", "--root", str(tmp_path)]) == 1 + + +def test_cli_requires_id_for_explicit_path(tmp_path): + f = _write(tmp_path / "aep-record.schema.json", get_schema("aep-record")) + assert cli.main(["check", str(f)]) == 2 + + +# --- published CLI (process-level) ---------------------------------------- +# The issue quotes `wasmagent-protocol check --id ` as the +# published check tooling. Exercise it as a real subprocess via the installed +# console entry (`python -m wasmagent_protocol`), not just in-process, so the +# exact published invocation form is covered for the PyPI package. The running +# interpreter always has the package importable (these tests import it), so +# `sys.executable -m wasmagent_protocol` is the faithful console-script path. +def _run_cli(args): + return subprocess.run( + [sys.executable, "-m", "wasmagent_protocol", *args], + capture_output=True, + text=True, + ) + + +def test_cli_process_explicit_match(tmp_path): + f = _write(tmp_path / "aep-record.schema.json", get_schema("aep-record")) + res = _run_cli(["check", str(f), "--id", "aep-record"]) + assert res.returncode == 0, res.stderr + + +def test_cli_process_explicit_drift(tmp_path): + drifted = dict(get_schema("aep-record")) + drifted["title"] = "DRIFTED" + f = _write(tmp_path / "aep-record.schema.json", drifted) + res = _run_cli(["check", str(f), "--id", "aep-record"]) + assert res.returncode == 1 + + +def test_cli_process_scan_clean(tmp_path): + _consumer_with_vendored(tmp_path, get_schema("aep-record"), dep=True) + res = _run_cli(["check", "--scan", "--root", str(tmp_path)]) + assert res.returncode == 0, res.stderr + + +def test_cli_process_scan_drift(tmp_path): + drifted = dict(get_schema("aep-record")) + drifted["title"] = "DRIFTED" + _consumer_with_vendored(tmp_path, drifted, dep=True) + res = _run_cli(["check", "--scan", "--root", str(tmp_path)]) + assert res.returncode == 1 + + +# --- adopt the drift gate -------------------------------------------------- +# Consumers "adopt the drift gate" by calling the reusable workflow. Guard that +# the shipped workflow is actually a workflow_call gate (so consumers can +# `uses:` it), that it runs the drift scan, and that the docs show the one-line +# adoption snippet — so adoption stays possible and documented. +def test_reusable_drift_workflow_is_adoptable(): + wf = REPO_ROOT / ".github" / "workflows" / "schema-drift.yml" + assert wf.is_file(), "reusable schema-drift workflow must ship from this repo" + text = wf.read_text(encoding="utf-8") + assert "workflow_call:" in text, "workflow must be callable via workflow_call" + assert "check --scan" in text, "workflow must run the drift scan" + for doc_name in ("README.md", "docs/CONTRACT-CHANGE-PROCESS.md"): + doc = (REPO_ROOT / doc_name).read_text(encoding="utf-8") + assert "schema-drift.yml@" in doc, f"{doc_name} must show the uses: adoption snippet"