openvibers/openvibe.shared Commits

Generate STATUS.json and README version blocks and wire the D41 docs-status check (#17)

OP OpenVibers committed 5 Oct 2026 · 1fea7ad4cd · 6 files, +378 −1

* Generate STATUS.json and README version blocks and wire the D41 docs-status check

- `scripts/docs-status.js` (new): D41 docs-currency check/generator modelled on `pin-drift.js`; reads the root `package.json`'s codeload pins for openvibe-contracts/sdk/shared/publishing, owns only `contracts`/`sdk`/`shared`/`updated` in STATUS.json and the README `<!-- versions:start/end -->` block (preserving every other STATUS key and any prose after a version), exits 1 on drift, and omits/removes the block for a repo with no pins.
- `test/docs-status.test.js` (new): auto-run by `test/run.js`; covers `pinsIn` (ranges aren't pins), prose-preserving rewrites, block append, current pins pass `--check`, a stale `STATUS.contracts` exits 1 naming the field and both versions, missing README markers get appended, and a no-pin repo omits the block without throwing.
- `.github/workflows/test.yml`: added the `docs currency` step after `pin drift` (`curl … docs-status.js | node - "$GITHUB_WORKSPACE" --check`), plus a `docs-status` input and the header "Steps, in order" line.
- `README.md`: added the "Checks (CI)" mention of `scripts/docs-status.js` and appended the generated versions block (openvibe-contracts v0.61.0, openvibe-sdk v0.20.4).
- `STATUS.json`: added the generated `sdk` field (`openvibe-sdk v0.20.4`) so the repo's own pins pass `--check`.
- `CHANGELOG.md`: Unreleased entry describing `scripts/docs-status.js` and the new CI step.

Test commands and results:
- `ov test docs-status` → `1 passed in 51 s` (`✓ test/docs-status.test.js`).
- `node test/docs-status.test.js` → `docs-status: all checks passed` (exit 0).
- `node scripts/docs-status.js . --check` → exit 0.
- Drift proof (read-only, `--check`): `node scripts/docs-status.js /mnt/120gb/openvibe-worktrees/OpenVibe.Network-agent-bump-openvibe-shared-v2.5.0-murxmsgw --check` → exit 1, flagging `STATUS.json contracts: pins openvibe-contracts v0.85.0 but STATUS.json contracts says v0.80.0` plus missing `sdk`/`shared` fields and README block.

Not done / notes: I did not add the step to this repo's own `.github/workflows/ci.yml` (the brief scoped it to the reusable `test.yml`; adding it there would curl a `main` script that doesn't exist until merge and fail the PR's CI). I did not commit, push, or install anything, as instructed.

* Turn the docs-status step off by default, test the README failure cases, and move updated only on a real change

Changes

ddiff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
iindex f7bbac3..928e5fa 100644
---- a/.github/workflows/test.yml
++++ b/.github/workflows/test.yml
@@@ -25,6 +25,12 @@ name: test
# pin drift scripts/pin-drift.js from OpenVibe.Shared main over the whole repository: a pin of
# openvibe-contracts/-shared/-sdk/-publishing that is not a published tag, or more than one
# minor behind the latest release, fails (roadmap WS-C task 5; `pin-drift: false` skips)
++# docs scripts/docs-status.js from OpenVibe.Shared main over the whole repository: STATUS.json's
++# contracts/sdk/shared fields and the README `<!-- versions:start/end -->` block must name the
++# pinned releases (plan D41). Off by default, because a consumer repository does not have the
++# README versions block yet and a default-on check would fail its CI; a repository turns it on
++# with `docs-status: true` once its README carries the `<!-- versions:start -->` block and its
++# STATUS.json contracts/sdk/shared fields match its pins
# floating scripts/floating-promises.js from OpenVibe.Shared main over the whole repository: an
# async call whose result nobody awaits is listed as a warning for now, and fails the build once
# every repository is clean (acorn installed first; plan T0)
@@@ -73,6 +79,10 @@ on:
description: Fail on drifted openvibe-* library pins (OpenVibe.Shared scripts/pin-drift.js).
type: boolean
default: true
++ docs-status:
++ description: Fail when STATUS.json or the README versions block is not current with the openvibe-* pins (OpenVibe.Shared scripts/docs-status.js). Off by default; set true once the repository's README carries the `<!-- versions:start -->` block and its STATUS.json fields match.
++ type: boolean
++ default: false
package-manager:
description: npm or pnpm (pnpm sets itself up from package.json's packageManager and caches the pnpm store).
type: string
@@@ -142,6 +152,10 @@ jobs:
if: ${{ inputs.pin-drift }}
working-directory: .
run: curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/pin-drift.js | node - "$GITHUB_WORKSPACE"
++ - name: docs currency
++ if: ${{ inputs.docs-status }}
++ working-directory: .
++ run: curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/docs-status.js | node - "$GITHUB_WORKSPACE" --check
- name: install acorn (floating promises check)
working-directory: .
run: npm i --no-save acorn@8 acorn-walk@8
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 5d77042..90033ed 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++- `scripts/docs-status.js` (new; plan D41 docs currency): reads a repository's openvibe-contracts/sdk/
++ shared/publishing codeload pins and keeps STATUS.json's `contracts`/`sdk`/`shared`/`updated` fields
++ and the README `<!-- versions:start -->`/`<!-- versions:end -->` block in step with them, preserving
++ every other STATUS key (and any prose after a version). `--check` reports drift and exits 1; a
++ repository that pins nothing omits the block. In write mode `updated` is rewritten only when one of
++ those fields really changed, so a rerun with no pin change touches nothing.
++- The reusable test workflow's "docs currency" step is **off by default** (`docs-status: false`):
++ consumer repositories do not have the README versions block yet, so a default-on check would fail
++ their CI. A repository turns it on with `docs-status: true` once its README carries the
++ `<!-- versions:start -->` block and its STATUS.json `contracts`/`sdk`/`shared` fields match its pins.
++
## 2.8.0 — 2026-10-04
- `openvibe-shared/test-runner`: `parallel: 'auto'` resolves to `max(1, min(8, cpus - 1))` (before, any
ddiff --git a/README.md b/README.md
iindex 730f4cb..28c3e4b 100644
---- a/README.md
++++ b/README.md
@@@ -23,7 +23,7 @@ were reconciled first.
| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
--| Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found) |
++| Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once), `scripts/docs-status.js` (`node scripts/docs-status.js <dir> [--check]`: STATUS.json's contracts/sdk/shared fields and the README `<!-- versions:start/end -->` block must name the pinned releases; `--check` exits 1 on drift) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found) |
Every module file has an explicit `exports` entry (no wildcards), so `require('openvibe-shared/navbar')`,
`require.resolve('openvibe-shared/package.json')` and the rest all resolve. It stays one package with
@@@ -800,3 +800,8 @@ A bad release is undone by pinning the previous tag; a released tag is never mov
---
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
++
++<!-- versions:start -->
++- openvibe-contracts: v0.61.0
++- openvibe-sdk: v0.20.4
++<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 166ea01..4b3f15a 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,6 +9,7 @@
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"contracts": "openvibe-contracts v0.61.0 (devDependency: the release manifest contract)",
++ "sdk": "openvibe-sdk v0.20.4",
"features": [
"the OpenVibe Frame: navbar, footer, panels, account switcher, notification UI with realtime",
"themes, design tokens and icons",
ddiff --git a/scripts/docs-status.js b/scripts/docs-status.js
nnew file mode 100644
iindex 0000000..ef11440
---- /dev/null
++++ b/scripts/docs-status.js
@@@ -0,0 +1,210 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Docs currency (plan D41): a repository's pinned openvibe-* releases are written in two places
++ * besides package.json — the STATUS.json fields (`contracts`, `sdk`, `shared`, `updated`) and a
++ * README block delimited by `<!-- versions:start -->` / `<!-- versions:end -->`. This script reads
++ * the pins and keeps those two in step, so STATUS.json and the README can never quietly describe a
++ * release the repository does not pin.
++ *
++ * node scripts/docs-status.js [dir] write the current pins into STATUS.json and README.md
++ * node scripts/docs-status.js [dir] --check report drift and exit 1 (writes nothing)
++ * curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/docs-status.js | node - [dir] --check
++ *
++ * A pin is a `https://codeload.github.com/OpenVibers/<repo>/tar.gz/refs/tags/vX.Y.Z` dependency
++ * (any of the four dependency fields) for openvibe-contracts, openvibe-sdk, openvibe-shared or
++ * openvibe-publishing; a range like ">=1.5.0" is not a pin and is skipped (the same rule as
++ * scripts/pin-drift.js). The script owns only those three STATUS.json fields and `updated`, and the
++ * README versions block: repository, stage, deployed, features and every other key are preserved,
++ * and prose after a version (e.g. "(devDependency: the release manifest contract)") is kept.
++ *
++ * Exit 0 = the docs match the pins (or the repository pins nothing), 1 = drift.
++ */
++const fs = require('fs');
++const path = require('path');
++
++const START = '<!-- versions:start -->';
++const END = '<!-- versions:end -->';
++const FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
++// The pins this check understands, and the STATUS.json field each owns (null = not tracked there).
++const LIBS = [
++ { pkg: 'openvibe-contracts', repo: 'OpenVibe.Contracts', field: 'contracts' },
++ { pkg: 'openvibe-sdk', repo: 'OpenVibe.SDK', field: 'sdk' },
++ { pkg: 'openvibe-shared', repo: 'OpenVibe.Shared', field: 'shared' },
++ { pkg: 'openvibe-publishing', repo: 'OpenVibe.Publishing', field: null },
++];
++const PIN = /OpenVibers\/(OpenVibe\.[A-Za-z]+)\/tar\.gz\/refs\/tags\/v(\d+\.\d+\.\d+)$/;
++const VERSION = /(\d+\.\d+\.\d+)/;
++
++const byPackage = new Map(LIBS.map((l) => [l.pkg, l]));
++const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
++
++/** The openvibe-* pins in a parsed package.json: `{ 'openvibe-sdk': '0.20.4', ... }`. */
++function pinsIn(pkg) {
++ const out = {};
++ for (const field of FIELDS) {
++ for (const [name, spec] of Object.entries((pkg && pkg[field]) || {})) {
++ const lib = byPackage.get(name);
++ if (!lib) continue;
++ const m = PIN.exec(String(spec));
++ if (m && m[1] === lib.repo) out[name] = m[2];
++ }
++ }
++ return out;
++}
++
++/** The root package.json's pins, or {} when it is absent or unreadable. */
++function pinsFor(dir) {
++ const file = path.join(dir, 'package.json');
++ if (!fs.existsSync(file)) return {};
++ try { return pinsIn(JSON.parse(fs.readFileSync(file, 'utf8'))); } catch { return {}; }
++}
++
++/** The status value for a pin, keeping prose after the version: "openvibe-contracts v0.61.0 (devDependency…)". */
++function statusValue(existing, name, version) {
++ const fresh = `${name} v${version}`;
++ if (typeof existing !== 'string' || !VERSION.test(existing)) return fresh;
++ return existing.replace(VERSION, version);
++}
++
++/** STATUS.json findings for every tracked pin. */
++function statusFindings(status, pins) {
++ const out = [];
++ for (const lib of LIBS) {
++ if (!lib.field || !(lib.pkg in pins)) continue;
++ const want = pins[lib.pkg];
++ const existing = status[lib.field];
++ if (typeof existing !== 'string' || !VERSION.test(existing)) {
++ out.push({ ok: false, file: 'STATUS.json', field: lib.field, reason: `pins ${lib.pkg} v${want} but STATUS.json has no ${lib.field} version` });
++ continue;
++ }
++ const have = VERSION.exec(existing)[1];
++ if (have !== want) out.push({ ok: false, file: 'STATUS.json', field: lib.field, reason: `pins ${lib.pkg} v${want} but STATUS.json ${lib.field} says v${have}` });
++ else out.push({ ok: true, file: 'STATUS.json', field: lib.field, reason: `${lib.pkg} v${want}` });
++ }
++ return out;
++}
++
++/** The README versions block's inner text for the current pins. */
++function versionsInner(pins) {
++ return LIBS.filter((l) => pins[l.pkg]).map((l) => `- ${l.pkg}: v${pins[l.pkg]}`).join('\n');
++}
++
++/** The whole delimited block. */
++function versionsBlock(pins) {
++ return `${START}\n${versionsInner(pins)}\n${END}`;
++}
++
++/** Indexes of the markers in a README, or null when the block is absent. */
++function blockSpan(readme) {
++ const s = readme.indexOf(START);
++ const e = readme.indexOf(END);
++ return s !== -1 && e > s ? { s, e } : null;
++}
++
++/** Add, replace or (with no pins) remove the versions block; every other byte is kept. */
++function applyBlock(readme, pins) {
++ const span = blockSpan(readme);
++ const block = Object.keys(pins).length ? versionsBlock(pins) : null;
++ if (!block) {
++ if (!span) return readme;
++ const before = readme.slice(0, span.s).replace(/\s+$/, '');
++ const after = readme.slice(span.e + END.length).replace(/^\s+/, '');
++ return after ? `${before}\n\n${after}` : `${before}\n`;
++ }
++ if (span) return readme.slice(0, span.s) + block + readme.slice(span.e + END.length);
++ return `${readme.replace(/\s*$/, '')}\n\n${block}\n`;
++}
++
++/** README findings for the pinned releases. */
++function readmeFindings(readme, pins) {
++ const wanted = LIBS.filter((l) => pins[l.pkg]);
++ if (!wanted.length) return [];
++ const span = blockSpan(readme);
++ if (!span) return [{ ok: false, file: 'README.md', field: 'versions', reason: `is missing the ${START} / ${END} block (run without --check to append it)` }];
++ const body = readme.slice(span.s, span.e + END.length);
++ const missing = wanted.filter((l) => !new RegExp(`${escapeRe(l.pkg)}\\s*:\\s*v?${escapeRe(pins[l.pkg])}`).test(body));
++ if (missing.length) return [{ ok: false, file: 'README.md', field: 'versions', reason: `block is stale for ${missing.map((l) => l.pkg).join(', ')}` }];
++ return [{ ok: true, file: 'README.md', field: 'versions', reason: `${wanted.length} pinned release(s)` }];
++}
++
++function readJson(file) {
++ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
++}
++
++/** Read the two documents and report drift; writes nothing. */
++function inspect(dir = '.') {
++ const pins = pinsFor(dir);
++ const statusPath = path.join(dir, 'STATUS.json');
++ const readmePath = path.join(dir, 'README.md');
++ const status = fs.existsSync(statusPath) ? readJson(statusPath) : null;
++ const readme = fs.existsSync(readmePath) ? fs.readFileSync(readmePath, 'utf8') : null;
++ const findings = [];
++ if (status) findings.push(...statusFindings(status, pins));
++ else if (Object.keys(pins).length) findings.push({ ok: false, file: 'STATUS.json', field: '-', reason: 'does not exist but the repository pins openvibe-* libraries' });
++ if (readme !== null) findings.push(...readmeFindings(readme, pins));
++ return { pins, status, readme, findings };
++}
++
++/** Rebuild STATUS.json's tracked fields in place: missing ones inserted beside the first tracked key. */
++function withStatusFields(status, pins, today) {
++ const tracked = LIBS.filter((l) => l.field).map((l) => l.field);
++ const updates = {};
++ for (const lib of LIBS) {
++ if (lib.field && pins[lib.pkg]) updates[lib.field] = statusValue(status[lib.field], lib.pkg, pins[lib.pkg]);
++ }
++ const out = {};
++ let placed = false;
++ for (const [key, value] of Object.entries(status)) {
++ if (key === 'updated') continue;
++ if (tracked.includes(key)) {
++ if (!placed) { for (const f of tracked) if (updates[f] !== undefined) out[f] = updates[f]; placed = true; }
++ out[key] = updates[key] !== undefined ? updates[key] : value;
++ continue;
++ }
++ out[key] = value;
++ }
++ if (!placed) for (const f of tracked) if (updates[f] !== undefined) out[f] = updates[f];
++ // `updated` moves only when a tracked field really changed, so a rerun with no pin change writes nothing.
++ const changed = tracked.some((f) => out[f] !== status[f]);
++ out.updated = changed || status.updated === undefined ? today : status.updated;
++ return out;
++}
++
++/** Write the pins into STATUS.json and README.md; returns the files written. */
++function update(dir = '.', { today = new Date().toISOString().slice(0, 10) } = {}) {
++ const { pins, status, readme } = inspect(dir);
++ const changed = [];
++ const statusPath = path.join(dir, 'STATUS.json');
++ if (status) {
++ const next = JSON.stringify(withStatusFields(status, pins, today), null, 2) + '\n';
++ const current = fs.readFileSync(statusPath, 'utf8');
++ if (next !== current) { fs.writeFileSync(statusPath, next); changed.push('STATUS.json'); }
++ }
++ const readmePath = path.join(dir, 'README.md');
++ if (readme !== null) {
++ const next = applyBlock(readme, pins);
++ if (next !== readme) { fs.writeFileSync(readmePath, next); changed.push('README.md'); }
++ }
++ return changed;
++}
++
++function main(dir = '.', { check = false } = {}) {
++ if (check) return inspect(dir);
++ const changed = update(dir);
++ return { ...inspect(dir), changed };
++}
++
++module.exports = { LIBS, START, END, pinsIn, pinsFor, statusValue, statusFindings, versionsInner, versionsBlock, applyBlock, readmeFindings, inspect, withStatusFields, update, main };
++
++if (require.main === module || !module.parent) {
++ const args = process.argv.slice(2);
++ const check = args.includes('--check');
++ const dir = args.find((a) => a !== '--check') || '.';
++ const r = main(dir, { check });
++ if (r.changed && r.changed.length) console.log(`docs-status: wrote ${r.changed.join(', ')}`);
++ for (const f of r.findings) console.log(`${f.ok ? 'ok ' : 'FAIL'} ${f.file} ${f.field}: ${f.reason}`);
++ const bad = r.findings.filter((f) => !f.ok);
++ console.log(bad.length ? `docs-status: ${bad.length} drifted` : 'docs-status: docs are current');
++ process.exit(bad.length ? 1 : 0);
++}
ddiff --git a/test/docs-status.test.js b/test/docs-status.test.js
nnew file mode 100644
iindex 0000000..11176f0
---- /dev/null
++++ b/test/docs-status.test.js
@@@ -0,0 +1,136 @@
++'use strict';
++// scripts/docs-status.js (plan D41): STATUS.json's tracked fields and the README versions block must
++// name the openvibe-* releases package.json pins. --check reports drift and exits 1.
++const assert = require('assert');
++const cp = require('child_process');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { inspect, pinsIn, pinsFor, statusValue, versionsBlock, applyBlock, update } = require('../scripts/docs-status');
++
++const root = path.join(__dirname, '..');
++const script = path.join(root, 'scripts', 'docs-status.js');
++const url = (repo, v) => `https://codeload.github.com/OpenVibers/${repo}/tar.gz/refs/tags/${v}`;
++const tmp = (tag) => fs.mkdtempSync(path.join(os.tmpdir(), `docs-status-${tag}-`));
++const write = (dir, name, text) => fs.writeFileSync(path.join(dir, name), text);
++const read = (dir, name) => fs.readFileSync(path.join(dir, name), 'utf8');
++
++// pinsIn reads only codeload tag pins, and only the four shared libraries; ranges and other names are skipped.
++assert.deepStrictEqual(pinsIn({
++ dependencies: { 'openvibe-shared': url('OpenVibe.Shared', 'v2.8.0'), express: '^4.0.0' },
++ devDependencies: { 'openvibe-contracts': url('OpenVibe.Contracts', 'v0.61.0') },
++ peerDependencies: { 'openvibe-publishing': '>=1.5.0' },
++}), { 'openvibe-shared': '2.8.0', 'openvibe-contracts': '0.61.0' }, 'a range is not a pin');
++
++// Prose after a version survives a rewrite.
++assert.strictEqual(statusValue('openvibe-contracts v0.60.0 (devDependency: the release manifest contract)', 'openvibe-contracts', '0.61.0'),
++ 'openvibe-contracts v0.61.0 (devDependency: the release manifest contract)');
++assert.strictEqual(statusValue(undefined, 'openvibe-sdk', '0.20.4'), 'openvibe-sdk v0.20.4');
++
++// The block is delimited, lists every pinned library and appends when the markers are absent.
++assert.strictEqual(versionsBlock({ 'openvibe-contracts': '0.61.0', 'openvibe-sdk': '0.20.4' }),
++ '<!-- versions:start -->\n- openvibe-contracts: v0.61.0\n- openvibe-sdk: v0.20.4\n<!-- versions:end -->');
++assert.match(applyBlock('# x\n', { 'openvibe-contracts': '0.61.0' }), /# x\n\n<!-- versions:start -->\n- openvibe-contracts: v0\.61\.0\n<!-- versions:end -->\n$/,
++ 'missing markers are appended');
++assert.strictEqual(applyBlock('a\n\n<!-- versions:start -->\n- openvibe-sdk: v0.1.0\n<!-- versions:end -->\n', {}), 'a\n', 'no pin removes the block');
++
++// The repository's own pins pass --check, and the two documents describe them.
++{
++ const r = inspect(root);
++ assert.deepStrictEqual(r.pins, { 'openvibe-contracts': '0.61.0', 'openvibe-sdk': '0.20.4' });
++ assert.deepStrictEqual(r.findings.filter((f) => !f.ok), [], 'the checked-in STATUS.json and README block are current');
++ const cli = cp.spawnSync(process.execPath, [script, root, '--check'], { encoding: 'utf8' });
++ assert.strictEqual(cli.status, 0, cli.stdout + cli.stderr);
++}
++
++// A stale STATUS.contracts field exits 1 and names the field.
++{
++ const dir = tmp('stale');
++ write(dir, 'package.json', JSON.stringify({ dependencies: {
++ 'openvibe-contracts': url('OpenVibe.Contracts', 'v0.99.0'),
++ 'openvibe-sdk': url('OpenVibe.SDK', 'v0.20.4'),
++ } }));
++ write(dir, 'STATUS.json', JSON.stringify({ repository: 'OpenVibers/X', stage: 'alpha', contracts: 'openvibe-contracts v0.80.0', features: ['keep'] }));
++ write(dir, 'README.md', `# X\n\n${versionsBlock({ 'openvibe-contracts': '0.99.0', 'openvibe-sdk': '0.20.4' })}\n`);
++ const cli = cp.spawnSync(process.execPath, [script, dir, '--check'], { encoding: 'utf8' });
++ assert.strictEqual(cli.status, 1, 'drift exits 1');
++ assert.match(cli.stdout, /STATUS\.json contracts: .*v0\.99\.0.*v0\.80\.0/, 'names the field and both versions');
++ // rewriting fixes it and preserves untouched keys
++ assert.deepStrictEqual(update(dir, { today: '2026-10-04' }), ['STATUS.json']);
++ const status = JSON.parse(read(dir, 'STATUS.json'));
++ assert.strictEqual(status.contracts, 'openvibe-contracts v0.99.0');
++ assert.strictEqual(status.sdk, 'openvibe-sdk v0.20.4');
++ assert.deepStrictEqual(status.features, ['keep']);
++ assert.strictEqual(status.updated, '2026-10-04');
++ assert.strictEqual(cp.spawnSync(process.execPath, [script, dir, '--check'], { encoding: 'utf8' }).status, 0);
++ fs.rmSync(dir, { recursive: true, force: true });
++}
++
++// A rerun with no pin change rewrites nothing, `updated` included.
++{
++ const dir = tmp('rerun');
++ const pins = { 'openvibe-contracts': '0.61.0', 'openvibe-sdk': '0.20.4' };
++ write(dir, 'package.json', JSON.stringify({ dependencies: {
++ 'openvibe-contracts': url('OpenVibe.Contracts', 'v0.61.0'),
++ 'openvibe-sdk': url('OpenVibe.SDK', 'v0.20.4'),
++ } }));
++ write(dir, 'STATUS.json', JSON.stringify({ repository: 'OpenVibers/W', contracts: 'openvibe-contracts v0.61.0', sdk: 'openvibe-sdk v0.20.4', updated: '2026-10-01' }, null, 2) + '\n');
++ write(dir, 'README.md', `# W\n\n${versionsBlock(pins)}\n`);
++ assert.deepStrictEqual(update(dir, { today: '2026-10-04' }), [], 'a later day with no pin change rewrites nothing');
++ assert.strictEqual(JSON.parse(read(dir, 'STATUS.json')).updated, '2026-10-01', 'the old `updated` is kept');
++ fs.rmSync(dir, { recursive: true, force: true });
++}
++
++// Missing README markers are appended following the pins.
++{
++ const dir = tmp('append');
++ write(dir, 'package.json', JSON.stringify({ devDependencies: { 'openvibe-contracts': url('OpenVibe.Contracts', 'v0.61.0') } }));
++ write(dir, 'STATUS.json', JSON.stringify({ repository: 'OpenVibers/Y', deployed: false }));
++ write(dir, 'README.md', '# Y\n\nA site.\n');
++ const changed = update(dir, { today: '2026-10-04' });
++ assert.deepStrictEqual(changed.sort(), ['README.md', 'STATUS.json']);
++ assert.match(read(dir, 'README.md'), /A site\.\n\n<!-- versions:start -->\n- openvibe-contracts: v0\.61\.0\n<!-- versions:end -->\n/);
++ assert.strictEqual(JSON.parse(read(dir, 'STATUS.json')).deployed, false, 'untouched keys are preserved');
++ assert.strictEqual(cp.spawnSync(process.execPath, [script, dir, '--check'], { encoding: 'utf8' }).status, 0);
++ fs.rmSync(dir, { recursive: true, force: true });
++}
++
++// A README with no versions markers fails --check and names the file.
++{
++ const dir = tmp('readme-missing');
++ write(dir, 'package.json', JSON.stringify({ devDependencies: { 'openvibe-contracts': url('OpenVibe.Contracts', 'v0.61.0') } }));
++ write(dir, 'STATUS.json', JSON.stringify({ repository: 'OpenVibers/M', contracts: 'openvibe-contracts v0.61.0' }));
++ write(dir, 'README.md', '# M\n\nNo versions block here.\n');
++ const cli = cp.spawnSync(process.execPath, [script, dir, '--check'], { encoding: 'utf8' });
++ assert.strictEqual(cli.status, 1, 'a missing README block exits 1');
++ assert.match(cli.stdout, /^FAIL README\.md versions: is missing the/m, 'names the missing README block');
++ fs.rmSync(dir, { recursive: true, force: true });
++}
++
++// A README block naming an old version fails --check and names the file.
++{
++ const dir = tmp('readme-stale');
++ write(dir, 'package.json', JSON.stringify({ devDependencies: { 'openvibe-contracts': url('OpenVibe.Contracts', 'v0.61.0') } }));
++ write(dir, 'STATUS.json', JSON.stringify({ repository: 'OpenVibers/M', contracts: 'openvibe-contracts v0.61.0' }));
++ write(dir, 'README.md', `# M\n\n${versionsBlock({ 'openvibe-contracts': '0.60.0' })}\n`);
++ const cli = cp.spawnSync(process.execPath, [script, dir, '--check'], { encoding: 'utf8' });
++ assert.strictEqual(cli.status, 1, 'a stale README block exits 1');
++ assert.match(cli.stdout, /^FAIL README\.md versions: block is stale for openvibe-contracts/m, 'names the stale README block');
++ fs.rmSync(dir, { recursive: true, force: true });
++}
++
++// A repository that pins nothing omits the block and does not throw.
++{
++ const dir = tmp('nopin');
++ write(dir, 'package.json', JSON.stringify({ dependencies: { express: '^4.0.0' } }));
++ write(dir, 'STATUS.json', JSON.stringify({ repository: 'OpenVibers/Z' }));
++ write(dir, 'README.md', `# Z\n\n<!-- versions:start -->\n- openvibe-sdk: v0.1.0\n<!-- versions:end -->\n`);
++ assert.deepStrictEqual(pinsFor(dir), {});
++ assert.deepStrictEqual(inspect(dir).findings, []);
++ assert.ok(update(dir, { today: '2026-10-04' }).includes('README.md'), 'the leftover block is removed');
++ assert.strictEqual(read(dir, 'README.md'), '# Z\n');
++ const cli = cp.spawnSync(process.execPath, [script, dir, '--check'], { encoding: 'utf8' });
++ assert.strictEqual(cli.status, 0, cli.stdout + cli.stderr);
++ fs.rmSync(dir, { recursive: true, force: true });
++}
++console.log('docs-status: all checks passed');