openvibers/openvibe.shared Commits
Generate STATUS.json and README version blocks and wire the D41 docs-status check (#17)
Changes
@@@ -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: booleandefault: 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: falsepackage-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@@@ -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@@@ -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 -->@@@ -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",nnew file mode 100644@@@ -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);++}nnew file mode 100644@@@ -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');
@@ -25,6 +25,12 @@ name: test
@@ -25,6 +25,12 @@ name: test
# pin drift scripts/pin-drift.js from OpenVibe.Shared main over the whole repository: a pin of
# 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
# 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)
# 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
# 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
# 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)
# every repository is clean (acorn installed first; plan T0)
@@ -73,6 +79,10 @@ on:
@@ -73,6 +79,10 @@ on:
description: Fail on drifted openvibe-* library pins (OpenVibe.Shared scripts/pin-drift.js).
description: Fail on drifted openvibe-* library pins (OpenVibe.Shared scripts/pin-drift.js).
type: boolean
type: boolean
default: true
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:
package-manager:
description: npm or pnpm (pnpm sets itself up from package.json's packageManager and caches the pnpm store).
description: npm or pnpm (pnpm sets itself up from package.json's packageManager and caches the pnpm store).
type: string
type: string
@@ -142,6 +152,10 @@ jobs:
@@ -142,6 +152,10 @@ jobs:
if: ${{ inputs.pin-drift }}
if: ${{ inputs.pin-drift }}
working-directory: .
working-directory: .
run: curl -fsSL https://raw.githubusercontent.com/OpenVibers/OpenVibe.Shared/main/scripts/pin-drift.js | node - "$GITHUB_WORKSPACE"
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)
- name: install acorn (floating promises check)
working-directory: .
working-directory: .
run: npm i --no-save acorn@8 acorn-walk@8
run: npm i --no-save acorn@8 acorn-walk@8
@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,17 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
## 2.8.0 — 2026-10-04
- `openvibe-shared/test-runner`: `parallel: 'auto'` resolves to `max(1, min(8, cpus - 1))` (before, any
- `openvibe-shared/test-runner`: `parallel: 'auto'` resolves to `max(1, min(8, cpus - 1))` (before, any
@@ -23,7 +23,7 @@ were reconciled first.
@@ -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()` |
| 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`) |
| 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` |
| 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')`,
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
`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
@@ -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).
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 -->
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
"deployed": false,
"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)",
"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)",
"contracts": "openvibe-contracts v0.61.0 (devDependency: the release manifest contract)",
+ "sdk": "openvibe-sdk v0.20.4",
"features": [
"features": [
"the OpenVibe Frame: navbar, footer, panels, account switcher, notification UI with realtime",
"the OpenVibe Frame: navbar, footer, panels, account switcher, notification UI with realtime",
"themes, design tokens and icons",
"themes, design tokens and icons",
new file mode 100644
new file mode 100644
@@ -0,0 +1,210 @@
@@ -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);
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -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');