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.
This commit is contained in:
cproudlock
2026-08-14 15:41:27 -04:00
parent 4995456136
commit d1ba3a1a02
8 changed files with 153 additions and 24 deletions

View File

@@ -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 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 rule these are JWT-authenticated (a login token or a managed Personal Access
Token) and versioned by the plugin contract (`__contract_version__`, currently Token) and versioned by the plugin contract (`__contract_version__`; the
0.16.0). Behavior and stability guarantees are in **CONTRACT-STABILITY.md**; 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 sister sites should pin tight `core_version` ranges until the contract reaches
1.0. 1.0.

View File

@@ -8,12 +8,14 @@ the live code, not aspiration. The authoritative hook reference is
## Current version ## Current version
The plugin contract is at **0.16.0**, declared in `shopdb/__init__.py` as The plugin contract version is declared in `shopdb/__init__.py` as
`__contract_version__`. It is pre-1.0, which under semver means any 0.x minor `__contract_version__`, and the current value is in
bump is allowed to break the contract, and this project has used that latitude. [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 The product release version (`__version__`) is a separate series with its own
series with its own bump rules; see [ADR-007](adr/ADR-007-product-versioning-and-releases.md). bump rules; see [ADR-007](adr/ADR-007-product-versioning-and-releases.md).
Do not pin against it for compatibility - pin against `__contract_version__`. Do not pin against it for compatibility - pin against `__contract_version__`.
### 0.x history ### 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.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.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.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 The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0,
0.16.0; 0.12.0 through 0.15.0 are recorded in this table only. Earlier points 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 (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 `core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the
0.2 baseline. 0.2 baseline.

View File

@@ -115,7 +115,7 @@ venv\Scripts\flask seed reference-data
# Install the plugins this site tracks (registry lives in the gitignored # Install the plugins this site tracks (registry lives in the gitignored
# instance/plugins.json, so a fresh box starts with none installed). Run # 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, # computers, employees, geenforce, knowledgebase, machines, measuringtools,
# network, notifications, printedparts, printers, slides, usb, warranty. Install # network, notifications, printedparts, printers, slides, usb, warranty. Install
# only the ones this site wants: # only the ones this site wants:

View File

@@ -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 [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. 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 Pin a tight range in your `manifest.json`, per ADR-002 (pip-style `>=,<`),
__contract_version__ = '0.13.0' admitting only the contract minor you tested against. With the contract at
``` 0.19.0 that would be:
Recommended pin in your `manifest.json`, per ADR-002 (pip-style `>=,<` ranges):
```json ```json
{ {
"name": "shipping", "name": "shipping",
"version": "1.0.0", "version": "1.0.0",
"description": "Tracks shipping-station scanners and label printers", "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": [] "dependencies": []
} }
``` ```

View File

@@ -78,7 +78,8 @@ plugin's identity (ADR-002):
Two fields deserve attention. Two fields deserve attention.
`core_version` is a semver range against the framework's `__contract_version__` `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 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 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 (see [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md), "get_reports"). We cap at `<1.0.0` because

View File

@@ -6,11 +6,11 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
## Contract version ## Contract version
The framework declares its contract version in `shopdb/__init__.py`: The framework declares its contract version in `shopdb/__init__.py` as
`__contract_version__`. The current value is in
```python [PROJECT-MAP.md](PROJECT-MAP.md), which is generated from the code - this page
__contract_version__ = '0.19.0' 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: Each plugin's `manifest.json` declares the range of contract versions it supports:

View File

@@ -1,6 +1,6 @@
# Roadmap # 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 ## Phase status

115
tests/test_docs_versions.py Normal file
View File

@@ -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