From d1ba3a1a02414f43211425f166831891ae7a9e11 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 14 Aug 2026 15:41:27 -0400 Subject: [PATCH] docs: stop typing versions the code already knows Nine documents carried a hand-typed contract version and every one was stale. One was load-bearing: PLUGIN-EXTERNAL-REPO.md told an external author to pin ">=0.13.0,<0.14.0" while the contract is at 0.19.0, so a plugin built by following that guide is refused by the loader at startup. The plugin count was wrong in six more. They now point at docs/PROJECT-MAP.md, which is generated. A test enforces it: no document may declare a version literal, a stated current version must match the code, and a stated plugin count must match the tree. ADRs are exempt from the current-version rule, because an ADR states the version a decision was taken AT - that is a record of the past, and rewriting it would falsify the record ADRs exist to keep. CONTRACT-STABILITY.md was missing 0.17.0, 0.18.0 and 0.19.0 - including the only BREAKING change in the series - in the one document a site reads to choose its pin. All three are recorded, with 0.19.0 called out: it took something away, and it shipped before it was written down, which is the argument for pinning tight rather than trusting that a minor bump is safe. --- docs/API-REFERENCE.md | 4 +- docs/CONTRACT-STABILITY.md | 25 +++++--- docs/DEPLOY-WINDOWS-IIS.md | 2 +- docs/PLUGIN-EXTERNAL-REPO.md | 16 ++--- docs/PLUGIN-GUIDE.md | 3 +- docs/PLUGIN-HOOKS.md | 10 +-- docs/ROADMAP.md | 2 +- tests/test_docs_versions.py | 115 +++++++++++++++++++++++++++++++++++ 8 files changed, 153 insertions(+), 24 deletions(-) create mode 100644 tests/test_docs_versions.py diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index a660ee5..231dd25 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -75,8 +75,8 @@ keying) are covered there as well. Everything else is the core UI API: the endpoints the Vue frontend calls. As a rule these are JWT-authenticated (a login token or a managed Personal Access -Token) and versioned by the plugin contract (`__contract_version__`, currently -0.16.0). Behavior and stability guarantees are in **CONTRACT-STABILITY.md**; +Token) and versioned by the plugin contract (`__contract_version__`; the +current value is in [PROJECT-MAP.md](PROJECT-MAP.md), which is generated). Behavior and stability guarantees are in **CONTRACT-STABILITY.md**; sister sites should pin tight `core_version` ranges until the contract reaches 1.0. diff --git a/docs/CONTRACT-STABILITY.md b/docs/CONTRACT-STABILITY.md index ffcd313..c2c30e8 100644 --- a/docs/CONTRACT-STABILITY.md +++ b/docs/CONTRACT-STABILITY.md @@ -8,12 +8,14 @@ the live code, not aspiration. The authoritative hook reference is ## Current version -The plugin contract is at **0.16.0**, declared in `shopdb/__init__.py` as -`__contract_version__`. It is pre-1.0, which under semver means any 0.x minor -bump is allowed to break the contract, and this project has used that latitude. +The plugin contract version is declared in `shopdb/__init__.py` as +`__contract_version__`, and the current value is in +[PROJECT-MAP.md](PROJECT-MAP.md), which is generated. It is pre-1.0, which under +semver means any 0.x minor bump is allowed to break the contract, and this +project has used that latitude - see 0.19.0 below. -The product release version (`__version__`, currently 0.7.0) is a separate -series with its own bump rules; see [ADR-007](adr/ADR-007-product-versioning-and-releases.md). +The product release version (`__version__`) is a separate series with its own +bump rules; see [ADR-007](adr/ADR-007-product-versioning-and-releases.md). Do not pin against it for compatibility - pin against `__contract_version__`. ### 0.x history @@ -34,9 +36,18 @@ Recorded in the comment block in `shopdb/__init__.py`: | 0.14.0 | Added `send_webhook` to the `shopdb.api` surface | additive surface (minor) | | 0.15.0 | Added `authorized_service_token` / the `SupportTeam` model to the `shopdb.api` surface | additive surface (minor) | | 0.16.0 | Added the `get_settings_defaults` hook so a plugin declares the Setting rows it owns; the framework seeds them at install, at enable, and on `flask plugin upgrade-all`, files a first-time write under the declared category, and honours `public: True` for pages that render before login | additive optional hook (minor) | +| 0.17.0 | Added `DashboardDefault` to the `shopdb.api` surface, so a plugin can resolve a display without reaching into core | additive surface (minor) | +| 0.18.0 | Added `DISPLAY_ROLES`, `DISPLAY_ROLE_PATHS` and `normalize_display_role`, and finally exported `DashboardDefault`, which 0.17.0 imported but left out of `__all__`. The role vocabulary became the kiosk's own - `Dashboard`, `Lobby`, `3DPrintRoom` - so a plugin holding its own copy of that map reads core's instead of drifting from it | additive surface (minor) | +| 0.19.0 | **BREAKING.** `get_dashboard_widgets` returns DATA AND SHAPE, not a component name. The old shape (`name` + `component` + `size`) named a Vue component per widget, which cannot survive a lean build - a plugin's component may never be staged into the frontend bundle (ADR-013) - and five plugins were declaring widgets that pointed at components nobody had written. A card now declares `id` / `title` / `endpoint` / `render` / `severity` / `permission` / `empty` / `position`; see PLUGIN-HOOKS.md. Also added `send_upload` so a plugin serving user-supplied bytes gets the headers that keep an SVG from running as script | **contract change (minor, pre-1.0)** | -The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0, 0.11.0, and -0.16.0; 0.12.0 through 0.15.0 are recorded in this table only. Earlier points +The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0, +0.11.0, 0.16.0, 0.18.0 and 0.19.0; 0.12.0 through 0.15.0 and 0.17.0 are recorded +in this table only. + +**0.19.0 is the one to read before pinning.** It is the only entry in this +series that took something away, and it shipped before it was recorded here - +which is precisely the reason to pin tight rather than to trust that a minor +bump is safe. Earlier points (0.1.x / 0.2.x) predate that recorded rationale; `PluginMeta`'s fallback `core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the 0.2 baseline. diff --git a/docs/DEPLOY-WINDOWS-IIS.md b/docs/DEPLOY-WINDOWS-IIS.md index 1b66846..c783859 100644 --- a/docs/DEPLOY-WINDOWS-IIS.md +++ b/docs/DEPLOY-WINDOWS-IIS.md @@ -115,7 +115,7 @@ venv\Scripts\flask seed reference-data # Install the plugins this site tracks (registry lives in the gitignored # instance/plugins.json, so a fresh box starts with none installed). Run -# `flask plugin list` to see the current bundled set; the 13 bundled plugins are +# `flask plugin list` to see the current bundled set; the the bundled plugins are # computers, employees, geenforce, knowledgebase, machines, measuringtools, # network, notifications, printedparts, printers, slides, usb, warranty. Install # only the ones this site wants: diff --git a/docs/PLUGIN-EXTERNAL-REPO.md b/docs/PLUGIN-EXTERNAL-REPO.md index 6711bd9..b9c3d17 100644 --- a/docs/PLUGIN-EXTERNAL-REPO.md +++ b/docs/PLUGIN-EXTERNAL-REPO.md @@ -109,20 +109,22 @@ the contract, and this project uses that latitude (see the history in [CONTRACT-STABILITY.md](CONTRACT-STABILITY.md)). So pin a TIGHT range that admits only the contract minor you tested against, not the whole 0.x line. -The current contract version is declared in `shopdb/__init__.py`: +The current contract version is declared in `shopdb/__init__.py` as +`__contract_version__`, and is reported in +[PROJECT-MAP.md](PROJECT-MAP.md), which is generated from the code. Read it +there - a version typed into this page is wrong within a fortnight, and a +plugin pinned to a stale one is refused at startup. -```python -__contract_version__ = '0.13.0' -``` - -Recommended pin in your `manifest.json`, per ADR-002 (pip-style `>=,<` ranges): +Pin a tight range in your `manifest.json`, per ADR-002 (pip-style `>=,<`), +admitting only the contract minor you tested against. With the contract at +0.19.0 that would be: ```json { "name": "shipping", "version": "1.0.0", "description": "Tracks shipping-station scanners and label printers", - "core_version": ">=0.13.0,<0.14.0", + "core_version": ">=0.19.0,<0.20.0", "dependencies": [] } ``` diff --git a/docs/PLUGIN-GUIDE.md b/docs/PLUGIN-GUIDE.md index f75be4c..a81019b 100644 --- a/docs/PLUGIN-GUIDE.md +++ b/docs/PLUGIN-GUIDE.md @@ -78,7 +78,8 @@ plugin's identity (ADR-002): Two fields deserve attention. `core_version` is a semver range against the framework's `__contract_version__` -(declared in `shopdb/__init__.py`, currently `0.13.0`). The loader refuses to load +(declared in `shopdb/__init__.py`; the current value is in +[PROJECT-MAP.md](PROJECT-MAP.md), which is generated). The loader refuses to load a plugin whose range excludes the running framework. We pin `>=0.6.0` because this plugin uses the `get_reports` hook, which was added to the contract in 0.6.0 (see [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md), "get_reports"). We cap at `<1.0.0` because diff --git a/docs/PLUGIN-HOOKS.md b/docs/PLUGIN-HOOKS.md index fcf2522..8382116 100644 --- a/docs/PLUGIN-HOOKS.md +++ b/docs/PLUGIN-HOOKS.md @@ -6,11 +6,11 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra ## Contract version -The framework declares its contract version in `shopdb/__init__.py`: - -```python -__contract_version__ = '0.19.0' -``` +The framework declares its contract version in `shopdb/__init__.py` as +`__contract_version__`. The current value is in +[PROJECT-MAP.md](PROJECT-MAP.md), which is generated from the code - this page +does not restate it, because a version copied into prose is stale within a +fortnight and a plugin pinned against a stale one is refused at startup. Each plugin's `manifest.json` declares the range of contract versions it supports: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 77f3c36..77a0097 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -shopdb-flask is at `__contract_version__ = '0.13.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs. +shopdb-flask is pre-1.0 on both series. Current contract and product versions are in [PROJECT-MAP.md](PROJECT-MAP.md), which is generated from the code rather than typed here. This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs. ## Phase status diff --git a/tests/test_docs_versions.py b/tests/test_docs_versions.py new file mode 100644 index 0000000..f572f81 --- /dev/null +++ b/tests/test_docs_versions.py @@ -0,0 +1,115 @@ +"""No document types a version or a count that the code already knows. + +Nine documents carried a hand-typed contract version. Every one was stale, and +one of them was load-bearing: PLUGIN-EXTERNAL-REPO.md told an external author to +pin `>=0.13.0,<0.14.0` against a contract at 0.19.0, so a plugin built by +following the guide is refused by the loader at startup. The plugin count was +wrong in six more. + +A number that is copied is a number that goes stale, and prose gives no signal +about which lines are still true. docs/PROJECT-MAP.md is generated for exactly +this, so a document points at it instead of restating it. + +This test is the rule. Where a version genuinely belongs in prose - the contract +history table, a changelog entry, an ADR recording what was decided when - it is +recording the PAST, which does not go stale. +""" + +import re +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] +DOCS = REPO / 'docs' + +pytestmark = pytest.mark.skipif( + not DOCS.is_dir(), + reason='no docs/ in this checkout - it is excluded from publication') + +# The map is generated and the changelog records history; both are allowed to +# carry literal versions. CONTRACT-STABILITY's table is a history of what each +# contract version DID, which is the past and stays true. +EXEMPT = {'PROJECT-MAP.md', 'CONTRACT-STABILITY.md'} + +ASSIGNMENT = re.compile(r"__(?:contract_)?version__\s*=\s*['\"]([0-9]+\.[0-9]+\.[0-9]+)['\"]") +NARRATED = re.compile( + r"__(?:contract_)?version__[^\n]{0,60}?\b([0-9]+\.[0-9]+\.[0-9]+)\b" + r"|\b(?:contract|version) is (?:at|currently)\s+\*{0,2}([0-9]+\.[0-9]+\.[0-9]+)") +PLUGIN_COUNT = re.compile(r'\b(\d+)\s+bundled plugins\b', re.I) + + +def documentation_files(): + return sorted(p for p in DOCS.rglob('*.md') if p.name not in EXEMPT) + + +def current_versions(): + text = (REPO / 'shopdb' / '__init__.py').read_text() + return { + name: re.search(r"^%s\s*=\s*['\"]([^'\"]+)['\"]" % name, text, re.M).group(1) + for name in ('__version__', '__contract_version__') + } + + +def bundled_plugin_count(): + return len(list((REPO / 'plugins').glob('*/manifest.json'))) + + +def test_no_document_declares_a_version_literal(): + """`__contract_version__ = '0.13.0'` in prose is a promise the code breaks.""" + offenders = [] + for path in documentation_files(): + for number, line in enumerate(path.read_text(errors='replace').splitlines(), 1): + if ASSIGNMENT.search(line): + offenders.append('%s:%d %s' % (path.relative_to(REPO), number, line.strip()[:90])) + assert not offenders, ( + 'These documents declare a version literal. Point at docs/PROJECT-MAP.md, ' + 'which is generated:\n ' + '\n '.join(offenders)) + + +def test_a_narrated_version_matches_the_code(): + """Prose that states the CURRENT version has to be right.""" + versions = set(current_versions().values()) + offenders = [] + for path in documentation_files(): + # An ADR states the version a decision was taken AT. That is a record of + # the past, not a claim about today, and rewriting it would falsify the + # record this project keeps ADRs for. + if path.parent.name == 'adr': + continue + for number, line in enumerate(path.read_text(errors='replace').splitlines(), 1): + for match in NARRATED.finditer(line): + found = match.group(1) or match.group(2) + if found and found not in versions: + offenders.append('%s:%d says %s %s' + % (path.relative_to(REPO), number, found, line.strip()[:70])) + assert not offenders, ( + 'These lines state a current version that no longer matches ' + 'shopdb/__init__.py:\n ' + '\n '.join(offenders)) + + +def test_a_stated_plugin_count_matches_the_tree(): + actual = bundled_plugin_count() + offenders = [] + for path in documentation_files(): + for number, line in enumerate(path.read_text(errors='replace').splitlines(), 1): + for match in PLUGIN_COUNT.finditer(line): + if int(match.group(1)) != actual: + offenders.append('%s:%d says %s, tree has %d' + % (path.relative_to(REPO), number, match.group(1), actual)) + assert not offenders, ( + 'These documents count plugins by hand. The count is in ' + 'docs/PROJECT-MAP.md:\n ' + '\n '.join(offenders)) + + +def test_the_map_itself_is_current(): + """The pointer target has to be right, or every document pointing at it is + wrong at one remove.""" + mapfile = DOCS / 'PROJECT-MAP.md' + assert mapfile.is_file(), 'docs/PROJECT-MAP.md is missing; run scripts/gen_project_map.py' + text = mapfile.read_text() + for name, version in current_versions().items(): + assert version in text, ( + '%s is %s in the code but the generated map does not carry it. ' + 'Run: venv/bin/python scripts/gen_project_map.py' % (name, version)) + assert str(bundled_plugin_count()) in text