From e2921370cfe9245cacde39adc8f6ddf9536fdc56 Mon Sep 17 00:00:00 2001 From: Apollo Date: Thu, 30 Jul 2026 07:26:34 -0500 Subject: [PATCH] feat: expose the package version at runtime (TS + Python) Adds `VERSION` (TypeScript) and `__version__` (Python) so a consumer can read which version of this package produced a document instead of hand-copying a version string into its own constant. WHY A consumer that records extraction provenance needs the producing module's version. Until now the package offered no way to obtain it, so hand-copying was the only option available -- and a hand-copied version is a claim about the runtime rather than evidence of it. One downstream consumer did exactly that and its copy went stale, declaring 0.5.0 while 0.6.0 was current, with nothing able to detect the drift. Asking consumers to keep a copy in sync is not a fix; it is the same failure deferred. Making the version readable removes the need. The IL already has a producer-provenance field (`produced_by`). This gives consumers the value that belongs in it. WHY A LITERAL AND NOT A READ OF package.json The default TypeScript entry must stay importable in browser and WASM hosts; `scripts/check-ts-universal-entry.mjs` fails the build if it reaches a Node built-in. So `VERSION` is an embedded literal, the same trade the embedded prompt fragments already make -- and it carries the same obligation an embedded copy always carries: a test that fails when it drifts from its source. DRIFT COVERAGE The version is now written in four places (two manifests, two runtime constants). `packages/ts/tests/test_version.ts` and `tests/python/test_version.py` tie all four together, including across languages: the two surfaces ship as one product at one version, and nothing enforced that before -- they were two hand-edited numbers that happened to agree. Proven by mutation rather than asserted. Each of the four locations was mutated in turn and a named test went red; both suites return green on restore: mutate ts/package.json -> pytest RED, vitest RED (cross-language tie) mutate python pyproject -> pytest RED, vitest RED (cross-language tie) mutate ts src/version.ts -> vitest RED mutate python __init__.py -> pytest RED The Python suite also compares `importlib.metadata` against the source constant. That reads what pip actually installed, which is a genuinely different source than the literal, so it catches a stale editable install or a bump that was never reinstalled -- something the other assertions cannot see. It skips rather than fails when no distribution is installed, since importing from a source tree is legitimate. A note for whoever runs the mutation proof next: clear `__pycache__` between steps. Bytecode invalidation compares source mtime at one-second granularity plus size, and two same-length version edits inside the same second are indistinguishable to it -- a stale `.pyc` served a mutated value after the file had been restored. The masking is silent and it cuts both ways, so a mutation result gathered without clearing the cache proves nothing. scripts/bump-version.sh Four hand-edited numbers is the shape that drifts, and this commit takes it from two to four, so the tooling comes with it. The script updates all four, rejects anything that is not a bare semver triple (a range or a full specifier would each be a plausible paste and each would corrupt recorded provenance), and verifies afterward that the four agree rather than reporting success for having run. It deliberately does not commit, tag, or push: under the dev/main model a tag belongs to the release ceremony on main, not to whatever branch is checked out when someone bumps a number. Premium boundary: extract is OSS. A package's own version is public by definition; no premium, identity, or org semantics are involved. --- .../python/src/synapt/extract/__init__.py | 12 ++ packages/ts/src/index.ts | 2 + packages/ts/src/version.ts | 18 +++ packages/ts/tests/test_version.ts | 58 ++++++++ scripts/bump-version.sh | 125 ++++++++++++++++++ tests/python/test_version.py | 86 ++++++++++++ 6 files changed, 301 insertions(+) create mode 100644 packages/ts/src/version.ts create mode 100644 packages/ts/tests/test_version.ts create mode 100755 scripts/bump-version.sh create mode 100644 tests/python/test_version.py diff --git a/packages/python/src/synapt/extract/__init__.py b/packages/python/src/synapt/extract/__init__.py index 1394933..8bcfd04 100644 --- a/packages/python/src/synapt/extract/__init__.py +++ b/packages/python/src/synapt/extract/__init__.py @@ -1,5 +1,16 @@ """synapt-extract: SynaptExtraction IL v1 schema, validation, and finalization.""" +#: The version of this package, available at runtime. +#: +#: Consumers that record which extractor produced a document should read this +#: rather than hand-copying a version string, so the recorded value is evidence +#: of what ran instead of a claim about it. +#: +#: Kept in step with ``packages/python/pyproject.toml`` and the TypeScript +#: package; ``scripts/bump-version.sh`` updates all three and +#: ``tests/python/test_version.py`` fails if any one of them drifts. +__version__ = "0.6.0" + from synapt.extract.schema import ( SynaptExtraction, SynaptEntity, @@ -128,4 +139,5 @@ "BatchUnit", "BatchUnitResult", "extract_batch", + "__version__", ] diff --git a/packages/ts/src/index.ts b/packages/ts/src/index.ts index 6dede24..0ed2da8 100644 --- a/packages/ts/src/index.ts +++ b/packages/ts/src/index.ts @@ -19,6 +19,8 @@ export type { } from "./schema.js"; export { EXTRACTION_CAPABILITIES } from "./schema.js"; +export { VERSION } from "./version.js"; + export { validateExtraction } from "./validate.js"; export type { ValidationResult, ValidationError } from "./validate.js"; diff --git a/packages/ts/src/version.ts b/packages/ts/src/version.ts new file mode 100644 index 0000000..c923774 --- /dev/null +++ b/packages/ts/src/version.ts @@ -0,0 +1,18 @@ +/** + * The version of this package, available at runtime. + * + * Consumers that record which extractor produced a document should read this + * rather than hand-copying a version string, so the recorded value is evidence + * of what ran instead of a claim about it. + * + * Deliberately a literal and not a read of `package.json`: the default entry + * has to stay importable in browser and WASM hosts, and + * `scripts/check-ts-universal-entry.mjs` fails the build if it reaches for a + * Node built-in. `tests/test_version.ts` ties this constant to the manifest and + * to the Python distribution, so a one-sided edit fails there rather than + * shipping. + * + * Keep in step with `packages/ts/package.json` and + * `packages/python/pyproject.toml`; `scripts/bump-version.sh` updates all three. + */ +export const VERSION = "0.6.0"; diff --git a/packages/ts/tests/test_version.ts b/packages/ts/tests/test_version.ts new file mode 100644 index 0000000..bff41f2 --- /dev/null +++ b/packages/ts/tests/test_version.ts @@ -0,0 +1,58 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { VERSION } from "../src/index.js"; + +const REPO_ROOT = resolve(import.meta.dirname, "..", "..", ".."); + +function packageJsonVersion(...parts: string[]): string { + const pkg = JSON.parse(readFileSync(resolve(...parts), "utf-8")) as { version?: string }; + if (typeof pkg.version !== "string") throw new Error(`no version in ${resolve(...parts)}`); + return pkg.version; +} + +/** The `version = "x.y.z"` line from a pyproject, without adding a TOML dependency. */ +function pyprojectVersion(path: string): string { + const match = /^version\s*=\s*"([^"]+)"/m.exec(readFileSync(path, "utf-8")); + if (match === null) throw new Error(`no version line in ${path}`); + return match[1]; +} + +/** + * WHY THIS FILE EXISTS + * + * A consumer that records which extractor produced a document needs to obtain + * the version FROM the package. Before `VERSION` existed there was no way to, + * so the only option we offered was hand-copying a string into a constant -- + * and a hand-copied version is a claim about the runtime, not evidence of it. + * A downstream consumer did exactly that and its copy went stale (declared + * 0.5.0 against a 0.6.0 runtime) with nothing able to detect it. + * + * `VERSION` is a literal rather than a read of package.json on purpose: the + * default entry must stay importable in browser/WASM hosts, and + * `scripts/check-ts-universal-entry.mjs` fails the build if it reaches for a + * Node built-in. That is the same trade the embedded prompt fragments make, so + * it carries the same obligation -- an embedded copy needs a test that fails + * when it drifts from its source. That is what this is. + */ +describe("VERSION", () => { + test("matches the TypeScript package manifest", () => { + expect(VERSION).toBe(packageJsonVersion(REPO_ROOT, "packages", "ts", "package.json")); + }); + + test("matches the Python distribution version", () => { + // The two language surfaces ship as one product at one version. Nothing + // enforced that before this test: they were two hand-edited numbers that + // happened to agree. + expect(VERSION).toBe(pyprojectVersion(resolve(REPO_ROOT, "packages", "python", "pyproject.toml"))); + }); + + test("is a bare semver triple, not a range or a specifier", () => { + // Guards the shape a consumer stamps into provenance. "^0.6.0" or + // "@synapt-dev/extract@0.6.0" would each be a plausible thing to paste + // here and each would corrupt the recorded value. + expect(VERSION).toMatch(/^\d+\.\d+\.\d+$/); + }); +}); diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 0000000..8662850 --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# Bump the synapt-extract version across every place it is written. +# +# Usage: +# ./scripts/bump-version.sh 0.7.0 +# ./scripts/bump-version.sh patch # 0.6.0 -> 0.6.1 +# ./scripts/bump-version.sh minor # 0.6.0 -> 0.7.0 +# ./scripts/bump-version.sh major # 0.6.0 -> 1.0.0 +# +# WHY THIS EXISTS +# +# The version is written in FOUR places: the two package manifests and the two +# runtime constants that let a consumer read the version instead of hand-copying +# it. Four hand-edited numbers is precisely the shape that drifts, and a stale +# version is not a cosmetic problem here -- it is recorded into downstream +# provenance as evidence of what produced a document. +# +# `tests/python/test_version.py` and `packages/ts/tests/test_version.ts` fail +# when any one of the four drifts. This script is how you avoid tripping them. +# +# It deliberately does NOT commit, tag, or push. Under the dev/main branching +# model a tag belongs to the release ceremony on `main`, not to whatever branch +# happens to be checked out when someone bumps a number. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +PYPROJECT="$REPO_ROOT/packages/python/pyproject.toml" +INIT_PY="$REPO_ROOT/packages/python/src/synapt/extract/__init__.py" +TS_PKG="$REPO_ROOT/packages/ts/package.json" +TS_VERSION="$REPO_ROOT/packages/ts/src/version.ts" + +for f in "$PYPROJECT" "$INIT_PY" "$TS_PKG" "$TS_VERSION"; do + [ -f "$f" ] || { echo "Error: missing $f" >&2; exit 1; } +done + +# --- Read current version (pyproject is the reference) --- +CURRENT=$(grep '^version = ' "$PYPROJECT" | head -1 | sed 's/version = "\(.*\)"/\1/') +if [ -z "$CURRENT" ]; then + echo "Error: could not read version from $PYPROJECT" >&2 + exit 1 +fi + +IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + +ARG="${1:-}" +if [ -z "$ARG" ]; then + echo "Current version: $CURRENT" + echo "" + echo "Usage: $0 " + echo " $0 patch -> $MAJOR.$MINOR.$((PATCH + 1))" + echo " $0 minor -> $MAJOR.$((MINOR + 1)).0" + echo " $0 major -> $((MAJOR + 1)).0.0" + echo " $0 0.7.0 -> 0.7.0" + exit 0 +fi + +case "$ARG" in + patch) NEW="$MAJOR.$MINOR.$((PATCH + 1))" ;; + minor) NEW="$MAJOR.$((MINOR + 1)).0" ;; + major) NEW="$((MAJOR + 1)).0.0" ;; + *) + if ! echo "$ARG" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Error: '$ARG' is not a bare semver triple (x.y.z) or patch/minor/major." >&2 + echo "A range or a specifier here would be written into consumer provenance." >&2 + exit 1 + fi + NEW="$ARG" + ;; +esac + +echo "Bumping $CURRENT -> $NEW" + +# --- Apply. Each pattern is anchored so it cannot match a dependency's version. +python3 - "$NEW" "$PYPROJECT" "$INIT_PY" "$TS_PKG" "$TS_VERSION" <<'PY' +import re +import sys + +new, pyproject, init_py, ts_pkg, ts_version = sys.argv[1:6] + +def sub(path, pattern, replacement): + text = open(path, encoding="utf-8").read() + updated, n = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) + if n != 1: + raise SystemExit(f"Error: expected exactly 1 version match in {path}, found {n}") + open(path, "w", encoding="utf-8").write(updated) + +sub(pyproject, r'^version = "[^"]+"', f'version = "{new}"') +sub(init_py, r'^__version__ = "[^"]+"', f'__version__ = "{new}"') +sub(ts_pkg, r'^( "version": )"[^"]+"', rf'\g<1>"{new}"') +sub(ts_version, r'^export const VERSION = "[^"]+"', f'export const VERSION = "{new}"') +PY + +# --- Verify by fruit. The point of this script is that four numbers agree, so +# --- it checks that they do rather than reporting success for having run. +echo "" +echo "Verifying all four locations:" +FAIL=0 +check() { + local label="$1" actual="$2" + printf " %-34s %s" "$label" "$actual" + if [ "$actual" = "$NEW" ]; then echo " ok"; else echo " MISMATCH (expected $NEW)"; FAIL=1; fi +} +check "pyproject.toml" "$(grep '^version = ' "$PYPROJECT" | head -1 | sed 's/version = "\(.*\)"/\1/')" +check "python __init__.py" "$(grep '^__version__ = ' "$INIT_PY" | head -1 | sed 's/__version__ = "\(.*\)"/\1/')" +check "ts package.json" "$(grep '^ "version": ' "$TS_PKG" | head -1 | sed 's/.*: "\(.*\)".*/\1/')" +check "ts src/version.ts" "$(grep '^export const VERSION = ' "$TS_VERSION" | sed 's/.*"\(.*\)".*/\1/')" + +if [ "$FAIL" -ne 0 ]; then + echo "" + echo "Error: the four locations do not agree. Nothing was committed; fix before proceeding." >&2 + exit 1 +fi + +echo "" +echo "All four agree at $NEW. Next:" +echo " 1. REINSTALL the python package first: pip install -e packages/python" +echo " (test_installed_distribution_agrees... compares importlib.metadata against" +echo " the source constant, so it correctly reports RED until the installed" +echo " distribution is rebuilt at $NEW. That is the check doing its job, not a bug.)" +echo " 2. run the suites (pytest tests/python && cd packages/ts && npm test)" +echo " 3. PR the bump into dev" +echo " 4. release ceremony merges dev -> main, then 'gh release create v$NEW' cuts the tag" diff --git a/tests/python/test_version.py b/tests/python/test_version.py new file mode 100644 index 0000000..09d8534 --- /dev/null +++ b/tests/python/test_version.py @@ -0,0 +1,86 @@ +"""The runtime version is evidence, not a claim. + +WHY THIS FILE EXISTS + +A consumer that records which extractor produced a document needs to obtain the +version FROM the package. Before ``__version__`` existed there was no way to, so +the only option we offered was hand-copying a string -- and a hand-copied +version is a claim about the runtime rather than evidence of it. A downstream +consumer did exactly that and its copy went stale (declared 0.5.0 against a +0.6.0 runtime) with nothing able to detect the drift. + +These tests tie every place the version is written to every other place, so any +single-sided edit fails here instead of shipping. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "packages" / "python" / "src")) + +import synapt.extract +from synapt.extract import __version__ + +REPO_ROOT = Path(__file__).resolve().parents[2] +PYPROJECT = REPO_ROOT / "packages" / "python" / "pyproject.toml" +TS_PACKAGE_JSON = REPO_ROOT / "packages" / "ts" / "package.json" + +_SEMVER = re.compile(r"^\d+\.\d+\.\d+$") + + +def _pyproject_version() -> str: + match = re.search(r'^version\s*=\s*"([^"]+)"', PYPROJECT.read_text(), re.MULTILINE) + assert match is not None, f"no version line in {PYPROJECT}" + return match.group(1) + + +def _ts_package_version() -> str: + import json + + return json.loads(TS_PACKAGE_JSON.read_text())["version"] + + +def test_version_matches_the_distribution_metadata(): + assert __version__ == _pyproject_version() + + +def test_version_matches_the_typescript_package(): + """The two language surfaces ship as one product at one version. + + Nothing enforced this before: they were two hand-edited numbers that + happened to agree.""" + assert __version__ == _ts_package_version() + + +def test_version_is_exported_from_the_package_root(): + """A consumer must reach it without knowing the internal module layout.""" + assert synapt.extract.__version__ == __version__ + + +def test_version_is_a_bare_semver_triple(): + """Guards the shape stamped into provenance. A range (">=0.6.0") or a full + specifier ("@synapt-dev/extract@0.6.0") are each a plausible thing to paste + into this constant, and each would corrupt the recorded value.""" + assert _SEMVER.match(__version__), f"not a bare semver triple: {__version__!r}" + + +def test_installed_distribution_agrees_when_the_package_is_installed(): + """importlib.metadata reads what pip actually installed, which is a + genuinely different source than the literal in __init__.py -- so this + catches a stale editable install or a version-bump that never got reinstalled, + which the other assertions here cannot see. + + Skipped rather than failed when the package is imported from a source tree + with no installed distribution, since that is a legitimate way to use it. + """ + from importlib.metadata import PackageNotFoundError, version + + try: + installed = version("synapt-extract") + except PackageNotFoundError: + pytest.skip("synapt-extract is not installed as a distribution") + assert installed == __version__