openvibers/openvibe.contracts Commits

The docs check compares facts, not only headings and dates: versions, pins, consumer lists, deployed flags, README links and named paths and migrations; repositories with no manifest need a STATUS.json (it said 44/44 current while Sources reported 0 items) (#203)

OP OpenVibers committed 22 days ago · 0ca6035e46 · 5 files, +600 −31

Changes

ddiff --git a/README.md b/README.md
iindex c669bf5a..71ad7ad9 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Machine-readable contracts for the whole OpenVibe network.
--**Status:** alpha, v0.75.0 (first released in Wave 1). 589 schemas in `contracts/catalog.json`, 31 service manifests, 206 capability manifests, 13 namespace manifests and the staff capability map. Service repositories run `openvibe-contracts-check` and the pin-drift check in CI, and deployed services verify service and app tokens with `serviceAuth` in production. Consumers pin release tags (each repository's `STATUS.json` names its pin; `node scripts/docs-currency.js <root>` checks that it matches `package.json`). `node scripts/estate.js` generates `docs/ESTATE.md` and `manifests/repositories/` from the estate checkouts, and `--check` fails on drift (plan T1 step 3).
++**Status:** alpha (first released in Wave 1; the release is package.json's `version`, tagged). At v0.209.3: 798 schemas in `contracts/catalog.json`, 45 service manifests, 348 capability manifests, 13 namespace manifests and the staff capability map. Service repositories run `openvibe-contracts-check` and the pin-drift check in CI, and deployed services verify service and app tokens with `serviceAuth` in production. Consumers pin release tags (each repository's `STATUS.json` names its pin). `node scripts/docs-currency.js <root>` checks every checkout's README and `STATUS.json` against its code: the pins and versions they name, a library's consumer list, the deployed flag against the manifest, README links, and the paths and migrations they name. `node scripts/estate.js` generates `docs/ESTATE.md` and `manifests/repositories/` from the estate checkouts, and `--check` fails on drift (plan T1 step 3).
**Plan:** OpenVibe Development Roadmap, Wave 1 (implementation plan rev 3, §3.1 and §18.11).
**License:** AGPL-3.0 (same as every OpenVibe service).
@@@ -63,7 +63,7 @@ OpenAPI 3.1 per service: `generated/openapi/<service>.json` (index in `generated
| `events.event-envelope` | events | durable event shape for OpenVibe.Events (Wave 3) |
| `media.media-ref` | media | `med_<ULID>` (Wave 4) or transitional `legacy:<app>:<kind>:<id>` |
--Manifests: `manifests/services/` (30 services as of v0.34.0: 24 `alpha`, 1 `beta` and 5 `stable`; OpenVibe.Realtime has no manifest, as ADR-005 closed it before it was built; SDK, Shared and Publishing are released libraries and Examples a repository with CI, none of them a runtime), `manifests/capabilities/` (191 capabilities as of v0.34.0, 18 of them `planned`; the v0.1 set was Media upload/read, chat send, paste create, coins credit/debit, notifications push, subject resolve and Community post). Each active capability names the route that implements it today. Where a service is reachable today (public, loopback only, library) is Network's observed overlay (`server/registry/exposure.js` there); manifest notes say whether a service is deployed and launched publicly.
++Manifests: `manifests/services/` (45 services at v0.209.3: 39 `alpha`, 1 `beta` (Media), 4 `stable` (Games, Live, Network, Tools) and 1 `retired` (Sites, 2026-10-14); OpenVibe.Realtime has no manifest, as ADR-005 closed it before it was built; Contracts, SDK, Shared and Publishing are released libraries and Examples a repository with CI, none of them a runtime; `docs/ESTATE.md` tables every repository), `manifests/capabilities/` (348 capabilities at v0.209.3: 306 `active`, 27 `planned` and 15 `retired`; the v0.1 set was Media upload/read, chat send, paste create, coins credit/debit, notifications push, subject resolve and Community post). Each active capability names the route that implements it today. Where a service is reachable today (public, loopback only, library) is Network's observed overlay (`server/registry/exposure.js` there); manifest notes say whether a service is deployed and launched publicly.
**Ids.** Subjects use prefixed ULIDs: `usr_`, `gst_`, `app_`, `mod_`. Services and system actors use slugs (`live`, `media`). Events use `evt_` and Media objects `med_`.
@@@ -182,8 +182,8 @@ A run is answered inline, or with its job (202 + `Location`) while a job tool is
Callers are tiered: anonymous < session < user < app/service. Quotas count each tool's `cost` within
its `quotaClass`. yt is page-only (`api: false`). Only real tools have descriptors: there is no
`planned` status, and a planned or mirror id is `404 tools.tool.not_found`. The registry routes are
--served on the gateway and every satellite (`tools.tool.read` is `active` since v0.33.1); the run route
--and `tools.net.probe` are `planned` until Tools serves them.
++served on the gateway and every satellite (`tools.tool.read` is `active` since v0.33.1), and so is the run route
++(`tools.tool.run` and `tools.net.probe` are `active`).
`contracts.tools.checkDescriptor(d)` and `checkList(list)` apply the rules that JSON Schema cannot
express, such as `run.path` being the tool's own, and check each example's input against an embedded
@@@ -222,10 +222,11 @@ Each service manifest has a `lifecycle` block, filled from the service's code an
`ovhost validate <service>` (OpenVibe.Host) reads the block from the inventory or from this package's manifest. It fails when a field is missing, when `deadlineSeconds` exceeds the unit's TimeoutStopSec or the unit's KillSignal is a different signal, and when the checkout's installed openvibe-contracts is outside `contracts.range`.
--## Run job API (v0.90, planned)
++## Run job API (v0.90)
--OpenVibe.Run (`manifests/services/run.json`, a placeholder until the repository exists; plan T14 R1) takes jobs,
--places each on a node that advertises `worker:<class>` and sends it as `platform.job@1`. Its routes, all planned:
++OpenVibe.Run (`manifests/services/run.json`; plan T14 R1, deployed since 2026-10-07 and public at https://openvibe.run)
++takes jobs, places each on a node that advertises `worker:<class>` and sends it as `platform.job@1`. Its routes, under
++`https://openvibe.run/api/v1`:
| Route | Capability | Body | Answer |
| --- | --- | --- | --- |
@@@ -322,9 +323,9 @@ consumers pinning the previous tag; a published tag is never moved.
## Launch rule
--This repository does not make the product real, and the domain keeps its placeholder page on
--[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
--following exist here (plan §12.12):
++This repository does not make a product real: a product domain stays an entry in `manifests/products/<domain>.json`
++(OpenVibe.Sites, which served the placeholder pages, retired on 2026-10-14) until all of the following exist in the
++product's own repository (plan §12.12):
1. an owning runtime with health/readiness endpoints and observability;
2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
ddiff --git a/STATUS.json b/STATUS.json
iindex 81ea3fe6..4df2eee2 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -11,20 +11,20 @@
"deployment": "none of its own: a library and artifact repository published as release tags; services pin a tag and deploy it with their own releases",
"contracts": "openvibe-contracts (this package: package.json's version, released as the matching tag)",
"features": [
-- "589 JSON Schemas with valid and invalid fixtures",
-- "31 service manifests with lifecycle declarations",
-- "206 capability manifests and the staff capability map",
++ "798 JSON Schemas with valid and invalid fixtures (at v0.209.3)",
++ "45 service manifests with lifecycle declarations (39 alpha, 1 beta, 4 stable, 1 retired)",
++ "348 capability manifests (306 active, 27 planned, 15 retired) and the staff capability map",
"13 user-module namespace manifests",
"validate, ids, http (problem+json, trace and idempotency middleware), capabilities.check and serviceAuth",
"openvibe-contracts-check for service CI",
"generated TypeScript types, JSON Schema bundle and per-service OpenAPI 3.1",
"compatibility check against the previous tag (scripts/compat.js)",
-- "documentation-currency check (scripts/docs-currency.js)",
++ "documentation-currency check (scripts/docs-currency.js): README sections and STATUS fields, and the facts they state against the code (versions, pins, consumer lists, the deployed flag, README links, named paths and migrations)",
"generated estate: docs/ESTATE.md and manifests/repositories/ from the checkouts (scripts/estate.js; --check fails on drift)",
"chat.ticket-conversation@1 and the active chat.ticket.write capability: a per-ticket conversation keyed by ticket id and calling service, with author_kind person|agent|staff (POST /internal/chat/tickets/:ticket_id/messages and GET /internal/chat/tickets/:ticket_id)",
"chat.dm-block-state-result@1: Chat's DM block-state read ({ blocked }, GET /internal/chat/dm/block-state) joined to chat.messages.read's route list",
-- "platform.task@1 (planned, owner network) and ADR-044: one task at OpenVibe.Actor's router with a mode, a per-task and per-day budget, result, cost and explanation, SSE progress, cancel and webhooks",
-- "watch.watch@1 and the other Watch contracts (plan T18, planned), three watch events, four capabilities and the placeholder service manifest: user-defined persistent observations over pages, feeds, APIs, events and devices",
++ "platform.task@1 (owner network) and ADR-044: one task at OpenVibe.Actor's router with a mode, a per-task and per-day budget, result, cost and explanation, SSE progress, cancel and webhooks",
++ "watch.watch@1 and the other Watch contracts (plan T18), three watch events, four capabilities and Watch's service manifest: user-defined persistent observations over pages, feeds, APIs, events and devices",
"chat.site-daily-result@1, chat.first-chat-result@1 and chat.sound-result@1: Chat's three new internal reads (kind site-daily for Live's home series, the welcome check, and the sound a !command plays), with chat.stats-request@1 now accepting kind site-daily and the two route lists extended",
"services.resource.control (active, ADR-048): control operations routed through the owning authority via openvibe-sdk/resources, idempotent on idempotency_key, with WS-Z2 confirmations for sensitive actions",
"network.resource.read (active, ADR-048): OpenVibe.Network's resource index, GET /api/v1/resources (common.resource-list-result@1) and GET /api/v1/resources/:ovrn (common.resource-summary@1)",
ddiff --git a/scripts/docs-currency.js b/scripts/docs-currency.js
iindex 11185131..76e2b501 100644
---- a/scripts/docs-currency.js
++++ b/scripts/docs-currency.js
@@@ -22,7 +22,34 @@
* `contracts` names the openvibe-contracts tag package.json pins; `updated` is no older than the newest commit that
* touched package.json's pins (so a bump without a STATUS refresh is caught): checked by the date only.
*
-- * Nothing is fetched and nothing is written; git is read locally (log -1 on package.json).
++ * Every other checkout under the root named OpenVibe.* (a client or repository with no service manifest, such as
++ * OpenVibe.Node or OpenVibe.Extensions) needs a STATUS.json with repository, stage, deployed, features and updated, so
++ * the estate does not show it blank; its README is not held to the service headings. One whose STATUS `stage` is
++ * closed, retired or archived (OpenVibe.Realtime, closed by ADR-005) is skipped.
++ *
++ * Facts, for every checkout checked (structure and dates alone passed 44/44 on 2026-10-22 while STATUS versions,
++ * pins and consumer lists were months stale):
++ *
++ * version STATUS.json `version` equals package.json's `version`; without a package.json, the newest vX.Y.Z tag
++ * on the checkout's HEAD. So does a release the README's status line states after the stage
++ * ("**Status:** alpha, v0.75.0").
++ * pins every "openvibe-contracts|sdk|shared|publishing vX.Y.Z" in STATUS.json's contracts, sdk, shared,
++ * publishing and packages fields is a tag some package.json in the checkout pins (the root's, or a
++ * nested app's such as apps/server or examples/<name>, at most three levels down, never node_modules);
++ * a package nothing pins to an exact tag is not checked. The same for the README's
++ * `<!-- versions:start -->` block (openvibe-shared's docs-status writes it).
++ * consumers a STATUS.json `consumers` list (a library's) names every checkout under the root that pins the
++ * package, names none that does not, and any version an entry states is the one that consumer pins.
++ * deployed a service whose manifest's exposure is "live" does not say `deployed: false`.
++ * links a relative link or image in README.md (outside code) resolves to a file or directory in the
++ * checkout, as GitHub would resolve it.
++ * paths a path in a STATUS.json string that names a file (`server/x.js`) or directory (`server/x/`) under one
++ * of the checkout's own top-level directories exists; and a migration named by number
++ * ("migration 0012", `migrations/0012_x.sql`) in STATUS.json or the README is in migrations/. A string or
++ * line that names another repository (OpenVibe.Live, Live's …) is about that repository and is not read
++ * for paths.
++ *
++ * Nothing is fetched and nothing is written; git is read locally (log -1 on package.json, tags on HEAD).
*/
const fs = require('fs');
const path = require('path');
@@@ -39,6 +66,22 @@ const REQUIRED = [
['deploy', /^deploy|^deployment\b|^running it in production\b|^production\b/i],
];
const STATUS_FIELDS = ['repository', 'stage', 'deployed', 'contracts', 'features', 'updated'];
++/** A checkout with no service manifest (a client, a repository) is held to these. */
++const REPOSITORY_FIELDS = ['repository', 'stage', 'deployed', 'features', 'updated'];
++const CLOSED_STAGES = new Set(['closed', 'retired', 'archived']);
++const LIBS = ['openvibe-contracts', 'openvibe-sdk', 'openvibe-shared', 'openvibe-publishing'];
++const PIN_FIELDS = ['contracts', 'sdk', 'shared', 'publishing', 'packages'];
++const DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
++const MENTION = /\b(openvibe-(?:contracts|sdk|shared|publishing))(?:\s+|@)(v?\d+\.\d+\.\d+)\b/g;
++const SKIP_DIRS = new Set(['node_modules', 'vendor', 'dist', 'build', 'coverage']);
++const PACKAGE_DEPTH = 3;
++
++const readText = (file) => { try { return fs.readFileSync(file, 'utf8'); } catch { return null; } };
++const readJson = (file) => { const s = readText(file); if (s === null) return null; try { return JSON.parse(s); } catch { return null; } };
++const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
++const vTag = (v) => (String(v).startsWith('v') ? String(v) : `v${v}`);
++const plain = (v) => String(v).replace(/^v/, '');
++const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
function headings(readme) {
return readme.split('\n').filter((l) => /^##\s+/.test(l)).map((l) => l.replace(/^##\s+/, '').replace(/[`*_]/g, '').trim());
@@@ -52,6 +95,159 @@ function contractsPin(pkg) {
return m ? (m[1].startsWith('v') ? m[1] : `v${m[1]}`) : spec;
}
++/** An exact release a dependency spec pins (a codeload tag or a bare x.y.z), else null: a range pins nothing. */
++function exactTag(spec) {
++ const s = String(spec || '').trim();
++ const m = /refs\/tags\/(v?\d+\.\d+\.\d+)$/.exec(s) || /^v?(\d+\.\d+\.\d+)$/.exec(s);
++ return m ? vTag(m[1]) : null;
++}
++
++/** package.json files of a checkout: the root's first, then nested ones up to PACKAGE_DEPTH levels down. */
++function packageFiles(dir) {
++ const out = [];
++ const walk = (d, depth) => {
++ const f = path.join(d, 'package.json');
++ if (fs.existsSync(f)) out.push(f);
++ if (depth >= PACKAGE_DEPTH) return;
++ let entries = [];
++ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
++ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
++ if (!e.isDirectory() || e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
++ walk(path.join(d, e.name), depth + 1);
++ }
++ };
++ walk(dir, 0);
++ return out;
++}
++
++/**
++ * Every exact openvibe-* tag any package.json in the checkout pins: Map name → Set of tags. A library's own package
++ * in a nested template (Shared's templates/site) is not a pin of the library.
++ */
++function pinSets(dir) {
++ const any = new Map();
++ const own = (readJson(path.join(dir, 'package.json')) || {}).name;
++ for (const file of packageFiles(dir)) {
++ const pkg = readJson(file);
++ if (!pkg) continue;
++ for (const field of DEP_FIELDS) {
++ for (const [name, spec] of Object.entries(pkg[field] || {})) {
++ if (!LIBS.includes(name) || name === own) continue;
++ const tag = exactTag(spec);
++ if (!tag) continue;
++ if (!any.has(name)) any.set(name, new Set());
++ any.get(name).add(tag);
++ }
++ }
++ }
++ return any;
++}
++
++/** The STATUS strings that state pins: [{ field, name, tag }]. */
++function statusPinMentions(status) {
++ const out = [];
++ for (const field of PIN_FIELDS) {
++ const v = status[field];
++ const texts = Array.isArray(v) ? v.filter((x) => typeof x === 'string') : (typeof v === 'string' ? [v] : []);
++ for (const t of texts) for (const m of t.matchAll(MENTION)) out.push({ field, name: m[1], tag: vTag(m[2]) });
++ }
++ return out;
++}
++
++/** The README's `<!-- versions:start -->` block: [{ name, tag }]. */
++function versionsBlock(readme) {
++ const m = /<!--\s*versions:start\s*-->([\s\S]*?)<!--\s*versions:end\s*-->/.exec(readme || '');
++ if (!m) return [];
++ const out = [];
++ for (const x of m[1].matchAll(/\b(openvibe-(?:contracts|sdk|shared|publishing))\b\s*[:@]?\s*(v?\d+\.\d+\.\d+)/g)) out.push({ name: x[1], tag: vTag(x[2]) });
++ return out;
++}
++
++/**
++ * README lines outside fenced code. Inline code is removed (a link inside code is an example, not a link), or with
++ * keepCode only its backticks are (a migration named in code is still named).
++ */
++function proseLines(readme, keepCode = false) {
++ let fence = null;
++ return String(readme || '').split('\n').map((line) => {
++ const f = /^\s*(```+|~~~+)/.exec(line);
++ if (f) {
++ if (!fence) fence = f[1][0];
++ else if (f[1][0] === fence) fence = null;
++ return '';
++ }
++ if (fence) return '';
++ return keepCode ? line.replace(/`/g, ' ') : line.replace(/`[^`]*`/g, '``');
++ });
++}
++
++/** Relative link and image targets in the README's prose: [{ line, target }]. */
++function relativeLinks(readme) {
++ const out = [];
++ proseLines(readme).forEach((line, i) => {
++ const targets = [...line.matchAll(/\]\(\s*<?([^)\s>]+)>?(?:\s+["'(][^)]*)?\)/g)].map((m) => m[1]);
++ const ref = /^\s{0,3}\[[^\]]+\]:\s*<?(\S+?)>?(?:\s|$)/.exec(line);
++ if (ref) targets.push(ref[1]);
++ for (const t of targets) {
++ if (/^[a-z][a-z0-9+.-]*:/i.test(t) || t.startsWith('#') || t.startsWith('/') || t.startsWith('{')) continue;
++ const clean = t.replace(/[#?].*$/, '');
++ if (!clean) continue;
++ let decoded = clean;
++ try { decoded = decodeURIComponent(clean); } catch { /* keep it as written */ }
++ out.push({ line: i + 1, target: decoded });
++ }
++ });
++ return out;
++}
++
++/** Every string in a parsed STATUS.json, with the top-level field it sits under. */
++function statusStrings(status) {
++ const out = [];
++ const walk = (v, field) => {
++ if (typeof v === 'string') out.push({ field, text: v });
++ else if (Array.isArray(v)) for (const x of v) walk(x, field);
++ else if (v && typeof v === 'object') for (const x of Object.values(v)) walk(x, field);
++ };
++ for (const [k, v] of Object.entries(status || {})) walk(v, k);
++ return out;
++}
++
++/** A regex that matches text naming another repository than `self` (OpenVibe.Live, Live's, …). */
++function otherRepoPattern(self, names) {
++ const own = new Set([self, self.replace(/^OpenVibe\./, '')]);
++ const words = new Set();
++ for (const n of names) {
++ if (own.has(n)) continue;
++ words.add(n);
++ const short = n.replace(/^OpenVibe\./, '');
++ if (short && !own.has(short)) words.add(short);
++ }
++ if (!words.size) return null;
++ return new RegExp(`(^|[^A-Za-z0-9_.-])(${[...words].sort((a, b) => b.length - a.length).map(escapeRe).join('|')})(?![A-Za-z0-9_-])`);
++}
++
++/** Path-shaped tokens naming a file (`a/b.js`) or directory (`a/b/`) under one of the checkout's top-level dirs. */
++function pathTokens(text, topDirs) {
++ const out = [];
++ for (const m of String(text).matchAll(/(?:^|[\s(`'"[,;=])((?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)+(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\.[A-Za-z0-9]{1,6})?)(?=$|[\s)`'",;:\]]|\.(?:\s|$))/g)) {
++ const t = m[1];
++ const segs = t.split('/');
++ if (!topDirs.has(segs[0])) continue;
++ const fileLike = /\.[A-Za-z0-9]{1,6}$/.test(segs[segs.length - 1]);
++ if (!fileLike && !t.endsWith('/')) continue;
++ out.push(t);
++ }
++ return out;
++}
++
++/** Migrations named by number: "migration 0012", "migrations 0012_x", and (unless asPath is false) `migrations/0012_x.sql`. */
++function migrationMentions(text, asPath = true) {
++ const out = [];
++ const re = asPath ? /\bmigrations?(?:\s+|\/)`?(\d{3,4})(?:_[A-Za-z0-9_]+)?(?![A-Za-z0-9-])/gi : /\bmigrations?\s+`?(\d{3,4})(?:_[A-Za-z0-9_]+)?(?![A-Za-z0-9-])/gi;
++ for (const m of String(text).matchAll(re)) out.push(Number(m[1]));
++ return out;
++}
++
function lastPinChange(dir) {
try {
const out = execFileSync('git', ['-C', dir, 'log', '-1', '--format=%cI', '--', 'package.json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
@@@ -59,7 +255,149 @@ function lastPinChange(dir) {
} catch { return null; }
}
--function checkRepo(root, manifest) {
++/** The newest vX.Y.Z tag on the checkout's HEAD (a repository with no package.json releases by tag), else null. */
++function headTag(dir) {
++ if (!fs.existsSync(path.join(dir, '.git'))) return null;
++ try {
++ const out = execFileSync('git', ['-C', dir, 'tag', '--merged', 'HEAD', '--sort=-v:refname', '--list', 'v[0-9]*.[0-9]*.[0-9]*'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
++ return out.split('\n').map((s) => s.trim()).find(Boolean) || null;
++ } catch { return null; }
++}
++
++/**
++ * What the root holds, read once: the checkout names, each one's pins, and the manifests' repositories. `checkRepo`
++ * builds a one-off context when it is called without one (tests).
++ */
++function estateContext(root, manifests = []) {
++ const names = isDir(root) ? fs.readdirSync(root, { withFileTypes: true })
++ .filter((e) => !e.name.startsWith('.') && (e.isDirectory() || (e.isSymbolicLink() && isDir(path.join(root, e.name)))))
++ .map((e) => e.name).sort() : [];
++ const pins = new Map();
++ const pinsOf = (name) => {
++ if (!pins.has(name)) pins.set(name, pinSets(path.join(root, name)));
++ return pins.get(name);
++ };
++ // The repositories a text may name: the checkouts, the given manifests and every service manifest this package ships.
++ const repoNames = new Set(names);
++ const known = path.join(__dirname, '..', 'manifests', 'services');
++ const shipped = isDir(known) ? fs.readdirSync(known).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(known, f))).filter(Boolean) : [];
++ for (const m of manifests.concat(shipped)) { const n = String(m.repository || '').split('/').pop(); if (n) repoNames.add(n); }
++ return { root, names, pinsOf, repoNames: [...repoNames] };
++}
++
++/** The fact checks (see the header) for one checkout. */
++function factGaps(dir, name, status, ctx, manifest) {
++ const gaps = [];
++ const pkg = readJson(path.join(dir, 'package.json'));
++ const readme = readText(path.join(dir, 'README.md'));
++ const topDirs = new Set(isDir(dir) ? fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name) : []);
++ const other = otherRepoPattern(name, ctx.repoNames);
++ const aboutOther = (text) => Boolean(other && other.test(text));
++
++ // version
++ if (status && typeof status.version === 'string') {
++ const actual = pkg && pkg.version ? pkg.version : (headTag(dir) ? plain(headTag(dir)) : null);
++ const source = pkg && pkg.version ? 'package.json says' : 'the newest tag on HEAD is';
++ if (actual && plain(status.version) !== plain(actual)) gaps.push(`STATUS.json: version ${status.version}, ${source} ${actual}`);
++ }
++ // The README's status line states the release right after the stage ("**Status:** alpha, v0.75.0").
++ const statusLine = readme === null ? null : /^\*\*Status:\*\*\s*[A-Za-z-]+,\s*\**v?(\d+\.\d+\.\d+)\b/m.exec(readme);
++ if (statusLine && pkg && pkg.version && statusLine[1] !== plain(pkg.version)) gaps.push(`README: the status line says ${statusLine[1]}, package.json says ${pkg.version}`);
++
++ // pins
++ const pins = ctx.pinsOf(name);
++ const pinList = (lib) => [...(pins.get(lib) || [])].sort().join(' or ');
++ if (status) {
++ for (const { field, name: lib, tag } of statusPinMentions(status)) {
++ if (pins.has(lib) && !pins.get(lib).has(tag)) gaps.push(`STATUS.json: ${field} names ${lib} ${tag}, package.json pins ${pinList(lib)}`);
++ }
++ }
++ for (const { name: lib, tag } of versionsBlock(readme)) {
++ if (pins.has(lib) && !pins.get(lib).has(tag)) gaps.push(`README: the versions block names ${lib} ${tag}, package.json pins ${pinList(lib)}`);
++ }
++
++ // consumers
++ if (status && Array.isArray(status.consumers) && pkg && LIBS.includes(pkg.name)) {
++ const lib = pkg.name;
++ const pinning = new Map();
++ for (const n of ctx.names) {
++ if (n === name) continue;
++ const s = ctx.pinsOf(n).get(lib);
++ if (s && s.size) pinning.set(n, s);
++ }
++ const entries = status.consumers.filter((x) => typeof x === 'string');
++ const named = (n) => {
++ const short = n.replace(/^OpenVibe\./, '');
++ const re = new RegExp(`(^|[^A-Za-z0-9_.-])(${escapeRe(n)}|${escapeRe(short)})(?![A-Za-z0-9_-])`);
++ return entries.find((e) => re.test(e)) || null;
++ };
++ for (const [n, tags] of pinning) {
++ const entry = named(n);
++ if (!entry) { gaps.push(`STATUS.json: consumers leaves out ${n}, which pins ${lib} ${[...tags].sort().join(' or ')}`); continue; }
++ for (const m of entry.matchAll(/\bv(\d+\.\d+\.\d+)\b/g)) {
++ if (!tags.has(vTag(m[1]))) gaps.push(`STATUS.json: consumers says ${n} is on v${m[1]}, it pins ${[...tags].sort().join(' or ')}`);
++ }
++ }
++ for (const e of entries) {
++ const m = /^\s*(OpenVibe\.[A-Za-z0-9]+|OpenRestream)\b/.exec(e);
++ if (m && ctx.names.includes(m[1]) && !pinning.has(m[1])) gaps.push(`STATUS.json: consumers names ${m[1]}, which pins no ${lib}`);
++ }
++ }
++
++ // deployed
++ if (status && status.deployed === false && manifest && manifest.exposure && manifest.exposure.state === 'live') {
++ gaps.push('STATUS.json: deployed false, the service manifest\'s exposure is "live"');
++ }
++
++ // links
++ if (readme !== null) {
++ const seen = new Set();
++ for (const { line, target } of relativeLinks(readme)) {
++ if (seen.has(target)) continue;
++ seen.add(target);
++ const abs = path.resolve(dir, target);
++ if (abs !== dir && !abs.startsWith(dir + path.sep)) gaps.push(`README:${line}: links to ${target}, outside the repository`);
++ else if (!fs.existsSync(abs)) gaps.push(`README:${line}: links to ${target}, which is not in the checkout`);
++ }
++ }
++
++ // paths and migrations
++ const migDir = path.join(dir, 'migrations');
++ const migrations = isDir(migDir) ? fs.readdirSync(migDir) : null;
++ const numbers = migrations ? new Set(migrations.map((f) => /^(\d+)(?:[_.-]|$)/.exec(f)).filter(Boolean).map((m) => Number(m[1]))) : null;
++ const missingMigration = (num) => Boolean(numbers) && !numbers.has(num);
++ if (status) {
++ const reported = new Set();
++ for (const { field, text } of statusStrings(status)) {
++ if (aboutOther(text)) continue;
++ for (const t of pathTokens(text, topDirs)) {
++ if (reported.has(t) || fs.existsSync(path.join(dir, t.replace(/\/$/, '')))) continue;
++ reported.add(t);
++ gaps.push(`STATUS.json: ${field} names ${t}, which is not in the checkout`);
++ }
++ // A migrations/ path is a path token above; here only "migration 0012" in prose.
++ for (const num of migrationMentions(text, false)) {
++ if (reported.has(`m${num}`) || !missingMigration(num)) continue;
++ reported.add(`m${num}`);
++ gaps.push(`STATUS.json: ${field} names migration ${num}, which is not in migrations/`);
++ }
++ }
++ }
++ if (readme !== null && migrations) {
++ const reported = new Set();
++ proseLines(readme, true).forEach((line, i) => {
++ if (!line || aboutOther(line)) return;
++ for (const num of migrationMentions(line)) {
++ if (reported.has(num) || !missingMigration(num)) continue;
++ reported.add(num);
++ gaps.push(`README:${i + 1}: names migration ${num}, which is not in migrations/`);
++ }
++ });
++ }
++ return gaps;
++}
++
++function checkRepo(root, manifest, ctx) {
const name = String(manifest.repository || '').split('/').pop();
const dir = path.join(root, name);
const gaps = [];
@@@ -72,6 +410,7 @@ function checkRepo(root, manifest) {
if (manifest.status === 'placeholder') return { id: manifest.id, repository: manifest.repository, skipped: 'placeholder', gaps: [] };
return { id: manifest.id, repository: manifest.repository, missing: true, gaps: ['no checkout under the root'] };
}
++ const context = ctx || estateContext(root, [manifest]);
const readmePath = path.join(dir, 'README.md');
if (!fs.existsSync(readmePath)) gaps.push('README.md missing');
else {
@@@ -94,31 +433,67 @@ function checkRepo(root, manifest) {
const changed = lastPinChange(dir);
if (changed && status.updated && String(status.updated).slice(0, 10) < changed) gaps.push(`STATUS.json: updated ${status.updated}, package.json changed ${changed}`);
}
++ gaps.push(...factGaps(dir, name, status, context, manifest));
return { id: manifest.id, repository: manifest.repository, gaps };
}
++/**
++ * A checkout with no service manifest (OpenVibe.Node, OpenVibe.Extensions): it needs a STATUS.json the estate can
++ * read, and its facts are checked; the service README headings are not asked of it.
++ */
++function checkCheckout(root, name, ctx) {
++ const dir = path.join(root, name);
++ const id = name.replace(/^OpenVibe\./, '').toLowerCase();
++ const statusPath = path.join(dir, 'STATUS.json');
++ if (!fs.existsSync(statusPath)) return { id, repository: `OpenVibers/${name}`, kind: 'repository', gaps: ['STATUS.json missing (the estate shows the repository blank)'] };
++ const status = readJson(statusPath);
++ if (!status) return { id, repository: `OpenVibers/${name}`, kind: 'repository', gaps: ['STATUS.json is not JSON'] };
++ if (CLOSED_STAGES.has(String(status.stage || '').toLowerCase())) return { id, repository: status.repository || `OpenVibers/${name}`, kind: 'repository', skipped: 'closed', gaps: [] };
++ const gaps = [];
++ for (const f of REPOSITORY_FIELDS) if (status[f] === undefined) gaps.push(`STATUS.json: no "${f}"`);
++ if (status.repository && status.repository !== `OpenVibers/${name}`) gaps.push(`STATUS.json: repository ${status.repository}, the checkout is ${name}`);
++ const changed = lastPinChange(dir);
++ if (changed && status.updated && String(status.updated).slice(0, 10) < changed) gaps.push(`STATUS.json: updated ${status.updated}, package.json changed ${changed}`);
++ gaps.push(...factGaps(dir, name, status, ctx || estateContext(root), null));
++ return { id, repository: status.repository || `OpenVibers/${name}`, kind: 'repository', gaps };
++}
++
++/** Every result for a root: the services in manifest order, then the other OpenVibe.* checkouts (`only`: ids). */
++function collect(root, manifests, only) {
++ const ctx = estateContext(root, manifests);
++ const results = manifests.filter((m) => !only || only.has(m.id)).map((m) => checkRepo(root, m, ctx));
++ const covered = new Set(manifests.map((m) => String(m.repository || '').split('/').pop()).filter(Boolean));
++ for (const name of ctx.names) {
++ if (covered.has(name) || !name.startsWith('OpenVibe.')) continue;
++ if (only && !only.has(name.replace(/^OpenVibe\./, '').toLowerCase())) continue;
++ results.push(checkCheckout(root, name, ctx));
++ }
++ return results;
++}
++
/** The summary line: skipped placeholders and retired services are counted apart from the checkouts actually checked. */
function summarize(results) {
const placeholders = results.filter((r) => r.skipped === 'placeholder').length;
const retired = results.filter((r) => r.skipped === 'retired').length;
-- const checked = results.length - placeholders - retired;
++ const closed = results.filter((r) => r.skipped === 'closed').length;
++ const checked = results.length - placeholders - retired - closed;
const bad = results.filter((r) => r.gaps.length).length;
-- return `${checked - bad}/${checked} current${placeholders ? `, ${placeholders} placeholder skipped` : ''}${retired ? `, ${retired} retired skipped` : ''}`;
++ return `${checked - bad}/${checked} current${placeholders ? `, ${placeholders} placeholder skipped` : ''}${retired ? `, ${retired} retired skipped` : ''}${closed ? `, ${closed} closed skipped` : ''}`;
}
function main(argv) {
-- const root = argv.find((a) => !a.startsWith('--'));
++ const root = argv.find((a, i) => !a.startsWith('--') && argv[i - 1] !== '--only');
if (!root) { console.error('usage: docs-currency.js <root with the checkouts> [--json] [--only id,id]'); process.exit(2); }
const onlyArg = argv.indexOf('--only');
const only = onlyArg >= 0 ? new Set(String(argv[onlyArg + 1] || '').split(',').filter(Boolean)) : null;
const dir = path.join(__dirname, '..', 'manifests', 'services');
const manifests = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')))
-- .filter((m) => !only || only.has(m.id)).sort((a, b) => a.id.localeCompare(b.id));
-- const results = manifests.map((m) => checkRepo(path.resolve(root), m));
++ .sort((a, b) => a.id.localeCompare(b.id));
++ const results = collect(path.resolve(root), manifests, only);
if (argv.includes('--json')) console.log(JSON.stringify(results, null, 2));
else {
for (const r of results) {
-- if (r.skipped) console.log(`– ${r.id.padEnd(12)} ${r.skipped === 'retired' ? 'retired, nothing to document' : 'placeholder, no repository yet'}`);
++ if (r.skipped) console.log(`– ${r.id.padEnd(12)} ${r.skipped === 'retired' ? 'retired, nothing to document' : r.skipped === 'closed' ? 'closed, nothing to document' : 'placeholder, no repository yet'}`);
else console.log(`${r.gaps.length ? '✗' : '✓'} ${r.id.padEnd(12)} ${r.gaps.length ? r.gaps.join('; ') : 'current'}`);
}
console.log(summarize(results));
@@@ -127,4 +502,7 @@ function main(argv) {
}
if (require.main === module) main(process.argv.slice(2));
--module.exports = { checkRepo, headings, contractsPin, summarize, REQUIRED, STATUS_FIELDS };
++module.exports = {
++ checkRepo, checkCheckout, collect, estateContext, factGaps, headings, contractsPin, exactTag, pinSets, statusPinMentions,
++ versionsBlock, relativeLinks, pathTokens, migrationMentions, summarize, REQUIRED, STATUS_FIELDS, REPOSITORY_FIELDS,
++};
ddiff --git a/scripts/estate.js b/scripts/estate.js
iindex 1923e1e0..173aa068 100644
---- a/scripts/estate.js
++++ b/scripts/estate.js
@@@ -11,8 +11,11 @@
*
* A checkout is a directory under the root holding `.git`, `package.json` or `STATUS.json`. For each one:
*
-- * package.json version, engines.node and the openvibe-contracts/-sdk/-shared/-publishing pins
-- * STATUS.json repository, stage, runtime, domain, database, deployed and wave
++ * package.json version, engines.node and the openvibe-contracts/-sdk/-shared/-publishing pins; a pin the root
++ * package.json lacks is read from a nested one (apps/server, examples/<name>: at most three levels
++ * down, never node_modules), then from STATUS.json's prose
++ * STATUS.json repository, stage, runtime, domain, database, deployed and wave; version when there is no
++ * package.json (a Go repository such as OpenVibe.Node)
* migrations/ the count and the dialect the SQL declares
* this repository's manifests/services|products/*.json the service or product the checkout is
*
@@@ -106,8 +109,36 @@ function pinTag(text, name) {
return m ? (m[1].startsWith('v') ? m[1] : `v${m[1]}`) : null;
}
--function estatePins(pkg, status) {
++/** package.json files below a checkout's root, nearest first, at most three levels down (never node_modules). */
++function nestedPackages(dir) {
++ const out = [];
++ let level = [dir];
++ for (let depth = 1; depth <= 3 && level.length; depth++) {
++ const next = [];
++ for (const d of level) {
++ let entries = [];
++ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { continue; }
++ for (const e of entries.filter((x) => x.isDirectory() && !x.name.startsWith('.') && !['node_modules', 'vendor', 'dist', 'build', 'coverage'].includes(x.name)).sort((a, b) => a.name.localeCompare(b.name))) {
++ const sub = path.join(d, e.name);
++ const pkg = readJson(path.join(sub, 'package.json'));
++ if (pkg) out.push(pkg);
++ next.push(sub);
++ }
++ }
++ level = next;
++ }
++ return out;
++}
++
++function estatePins(pkg, status, dir) {
const pins = pinsOf(pkg);
++ if (dir && PIN_NAMES.some((name) => !pins[name])) {
++ for (const nested of nestedPackages(dir)) {
++ const found = pinsOf(nested);
++ // A library's own package in a nested template (Shared's templates/site) is not a pin of the library.
++ for (const name of PIN_NAMES) if (!pins[name] && name !== (pkg && pkg.name) && found[name] && /^v\d+\.\d+\.\d+$/.test(found[name])) pins[name] = found[name];
++ }
++ }
if (status) for (const name of PIN_NAMES) if (!pins[name]) {
const tag = pinTag(`${status.contracts || ''} ${status.sdk || ''}`, name);
if (tag) pins[name] = tag;
@@@ -133,13 +164,13 @@ function recordFor(name, dir, index) {
service: service ? service.id : null,
product: service ? ((service.site && service.site.name) || service.name) : (product ? product.name : (status && status.product) || null),
stage: (status && status.stage) || null,
-- version: (pkg && pkg.version) || null,
++ version: (pkg && pkg.version) || (status && typeof status.version === 'string' ? status.version : null),
runtime,
database: databaseOf(dir, status, pkg, exposure),
migrations: migrationFiles(dir).length,
authority: service ? ((service.namespacesOwned || []).length ? service.namespacesOwned.slice() : [service.id]) : [],
domains: domainsOf(status, manifest),
-- pins: estatePins(pkg, status),
++ pins: estatePins(pkg, status, dir),
// A retired service (its manifest says so) runs nowhere, whatever its archived STATUS.json last said.
deployed: exposure === 'retired' ? false : status ? (status.deployed === undefined ? null : status.deployed) : null,
exposure,
ddiff --git a/test/docs-currency.test.js b/test/docs-currency.test.js
iindex f1791fc8..e4d4d3b6 100644
---- a/test/docs-currency.test.js
++++ b/test/docs-currency.test.js
@@@ -6,7 +6,7 @@ const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
--const { checkRepo, summarize } = require('../scripts/docs-currency.js');
++const { checkRepo, collect, summarize, exactTag, pathTokens, migrationMentions } = require('../scripts/docs-currency.js');
let checks = 0;
const ok = (cond, msg) => { assert.ok(cond, msg); checks++; };
@@@ -57,4 +57,163 @@ try {
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
++// ── Facts (2026-10-22): structure and dates passed 44/44 while versions, pins and consumer lists were stale ──
++const facts = fs.mkdtempSync(path.join(os.tmpdir(), 'ovc-docs-facts-'));
++const tag = (repo, v) => `https://codeload.github.com/OpenVibers/${repo}/tar.gz/refs/tags/${v}`;
++const README = ['# X', '', '## Purpose', 'x', '## Owns', 'x', '## Does not own', 'x', '## Depends on', 'x',
++ '## Capabilities', 'x', '## Acceptance', 'x', '## Security', 'x', '## Deploy', 'x', ''].join('\n');
++/** A checkout under the facts root: files is { relative path: string, object (written as JSON) or null (a directory) }. */
++function checkout(name, files) {
++ const dir = path.join(facts, name);
++ fs.rmSync(dir, { recursive: true, force: true });
++ fs.mkdirSync(dir, { recursive: true });
++ for (const [rel, body] of Object.entries(files)) {
++ if (body === null) { fs.mkdirSync(path.join(dir, rel), { recursive: true }); continue; }
++ fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
++ fs.writeFileSync(path.join(dir, rel), typeof body === 'string' ? body : JSON.stringify(body, null, 2));
++ }
++ return dir;
++}
++const status = (name, extra = {}) => ({ repository: `OpenVibers/${name}`, stage: 'alpha', deployed: true, contracts: 'openvibe-contracts v0.9.0', features: ['x'], updated: '2099-01-01', ...extra });
++const service = (id, name, extra = {}) => ({ id, repository: `OpenVibers/${name}`, status: 'alpha', ...extra });
++const gapsOf = (r) => r.gaps.join('; ');
++try {
++ // version: STATUS.json's version is package.json's (Shared said 4.0.3 at 4.7.0).
++ checkout('OpenVibe.Lib', {
++ 'README.md': README,
++ 'package.json': { name: 'openvibe-lib', version: '4.7.0', devDependencies: { 'openvibe-contracts': tag('OpenVibe.Contracts', 'v0.9.0') } },
++ 'STATUS.json': status('OpenVibe.Lib', { version: '4.0.3' }),
++ });
++ let r = checkRepo(facts, service('lib', 'OpenVibe.Lib'));
++ ok(/version 4\.0\.3, package\.json says 4\.7\.0/.test(gapsOf(r)), `a stale STATUS version is a gap: ${gapsOf(r)}`);
++ checkout('OpenVibe.Lib', {
++ 'README.md': README,
++ 'package.json': { name: 'openvibe-lib', version: '4.7.0', devDependencies: { 'openvibe-contracts': tag('OpenVibe.Contracts', 'v0.9.0') } },
++ 'STATUS.json': status('OpenVibe.Lib', { version: '4.7.0' }),
++ });
++ r = checkRepo(facts, service('lib', 'OpenVibe.Lib'));
++ ok(r.gaps.length === 0, `a current library is current: ${gapsOf(r)}`);
++ fs.writeFileSync(path.join(facts, 'OpenVibe.Lib', 'README.md'), `**Status:** alpha, v2.10.1 (every release is a tag), since openvibe-shared v1.0.0.\n\n${README}`);
++ r = checkRepo(facts, service('lib', 'OpenVibe.Lib'));
++ ok(/the status line says 2\.10\.1, package\.json says 4\.7\.0/.test(gapsOf(r)) && r.gaps.length === 1, `a stale release on the README status line is a gap: ${gapsOf(r)}`);
++ fs.writeFileSync(path.join(facts, 'OpenVibe.Lib', 'README.md'), `**Status:** alpha, **4.7.0**: launched (openvibe-contracts 0.2.0).\n\n${README}`);
++ r = checkRepo(facts, service('lib', 'OpenVibe.Lib'));
++ ok(r.gaps.length === 0, `the current release on the status line is fine: ${gapsOf(r)}`);
++
++ // pins: every openvibe-* release STATUS names is one some package.json pins, nested apps included
++ // (Billing's "openvibe-sdk v0.28.0" at v0.60.0; Examples' contracts pin lives in examples/mod-manifest).
++ checkout('OpenVibe.Svc', {
++ 'README.md': `${README}\n<!-- versions:start -->\n- openvibe-sdk: v0.5.0\n<!-- versions:end -->\n`,
++ 'package.json': { name: 'openvibe-svc', version: '0.1.0', dependencies: { 'openvibe-contracts': tag('OpenVibe.Contracts', 'v0.9.0'), 'openvibe-sdk': tag('OpenVibe.SDK', 'v0.6.0') } },
++ 'apps/web/package.json': { name: 'web', dependencies: { 'openvibe-shared': tag('OpenVibe.Shared', 'v4.4.0') } },
++ 'STATUS.json': status('OpenVibe.Svc', { contracts: 'openvibe-contracts v0.9.0 (openvibe-sdk v0.2.8, openvibe-shared v2.13.0)', sdk: 'openvibe-sdk v0.6.0', packages: ['openvibe-shared v4.4.0', 'openvibe-publishing v1.0.0'] }),
++ });
++ r = checkRepo(facts, service('svc', 'OpenVibe.Svc'));
++ ok(/contracts names openvibe-sdk v0\.2\.8, package\.json pins v0\.6\.0/.test(gapsOf(r)), `a stale sdk named in contracts is a gap: ${gapsOf(r)}`);
++ ok(/contracts names openvibe-shared v2\.13\.0, package\.json pins v4\.4\.0/.test(gapsOf(r)), `a nested app's pin is the one compared: ${gapsOf(r)}`);
++ ok(!/packages names openvibe-shared v4\.4\.0/.test(gapsOf(r)), 'a release a nested app pins is current');
++ ok(!/openvibe-publishing/.test(gapsOf(r)), 'a package nothing pins to a tag is not checked');
++ ok(/versions block names openvibe-sdk v0\.5\.0, package\.json pins v0\.6\.0/.test(gapsOf(r)), `the README versions block is checked: ${gapsOf(r)}`);
++ checkout('OpenVibe.Svc', {
++ 'README.md': `${README}\n<!-- versions:start -->\n- openvibe-sdk: v0.6.0\n<!-- versions:end -->\n`,
++ 'package.json': { name: 'openvibe-svc', version: '0.1.0', dependencies: { 'openvibe-contracts': tag('OpenVibe.Contracts', 'v0.9.0'), 'openvibe-sdk': '>=0.1.0' }, devDependencies: { 'openvibe-sdk': tag('OpenVibe.SDK', 'v0.6.0') } },
++ 'node_modules/x/package.json': { name: 'x', dependencies: { 'openvibe-sdk': tag('OpenVibe.SDK', 'v0.1.0') } },
++ 'STATUS.json': status('OpenVibe.Svc', { contracts: 'openvibe-contracts v0.9.0 (actor-agent requires v0.1.0)', sdk: 'openvibe-sdk v0.6.0 (peer >=0.1.0)' }),
++ });
++ r = checkRepo(facts, service('svc', 'OpenVibe.Svc'));
++ ok(r.gaps.length === 0, `ranges, node_modules and versions not named after a package are not pins: ${gapsOf(r)}`);
++
++ // deployed: a manifest whose exposure is live is not "deployed: false" (Play, 2026-10-22).
++ checkout('OpenVibe.Play', { 'README.md': README, 'STATUS.json': status('OpenVibe.Play', { deployed: false }) });
++ r = checkRepo(facts, service('play', 'OpenVibe.Play', { exposure: { state: 'live' } }));
++ ok(/deployed false, the service manifest's exposure is "live"/.test(gapsOf(r)), `deployed false against a live manifest is a gap: ${gapsOf(r)}`);
++ r = checkRepo(facts, service('play', 'OpenVibe.Play', { exposure: { state: 'library' } }));
++ ok(!/deployed false/.test(gapsOf(r)), 'a library may say deployed false');
++
++ // links: a relative README link resolves in the checkout; code, URLs and anchors are not links.
++ checkout('OpenVibe.Docs', {
++ 'README.md': `${README}\nSee [the guide](docs/guide.md#start), [gone](docs/old.md), ![logo](img/logo.png), [up](../ARCHITECTURE.md),\n[site](https://openvibe.network), [here](#purpose) and \`[code](docs/none.md)\`.\n\n[ref]: docs/ref-gone.md\n\n\`\`\`md\n[example](docs/example.md)\n\`\`\`\n`,
++ 'docs/guide.md': '# Guide\n',
++ 'img/logo.png': 'png',
++ 'STATUS.json': status('OpenVibe.Docs'),
++ });
++ r = checkRepo(facts, service('docs', 'OpenVibe.Docs'));
++ ok(/links to docs\/old\.md, which is not in the checkout/.test(gapsOf(r)), `a broken relative link is a gap: ${gapsOf(r)}`);
++ ok(/links to docs\/ref-gone\.md/.test(gapsOf(r)), 'a reference-style link is checked');
++ ok(/links to \.\.\/ARCHITECTURE\.md, outside the repository/.test(gapsOf(r)), 'a link out of the repository is a gap');
++ ok(!/guide\.md|logo\.png|example\.md|none\.md|openvibe\.network|#purpose/.test(gapsOf(r)), `links that resolve, URLs, anchors and code are fine: ${gapsOf(r)}`);
++ ok(r.gaps.length === 3, `exactly the three broken links: ${gapsOf(r)}`);
++
++ // paths and migrations a STATUS string or README line names exist; another repository's are not read.
++ checkout('OpenVibe.Paths', {
++ 'README.md': `${README}\nMigration \`0002_more.sql\` adds the index; migration 0009 drops the old table.\nOpenVibe.Live's migration 0011 drops Live's.\n`,
++ 'migrations/0001_initial.sql': '-- phase: expand\n',
++ 'migrations/0002_more.sql': '-- phase: expand\n',
++ 'server/app.js': '',
++ 'STATUS.json': status('OpenVibe.Paths', {
++ features: ['the app (server/app.js)', 'the Arena desk (server/arena/)', 'scores in server/scores.js', 'migrations/0003_scores.sql adds them',
++ 'Live\'s server/streaming/recorder.js', 'docs at https://example.com/server/x.js', 'workers/status/prune commands'],
++ }),
++ });
++ r = checkRepo(facts, service('paths', 'OpenVibe.Paths'));
++ ok(/features names server\/arena\/, which is not in the checkout/.test(gapsOf(r)), `a deleted directory named by a feature is a gap: ${gapsOf(r)}`);
++ ok(/features names server\/scores\.js/.test(gapsOf(r)), 'a missing file named by a feature is a gap');
++ ok(/features names migrations\/0003_scores\.sql, which is not in the checkout/.test(gapsOf(r)) && !/features names migration 3\b/.test(gapsOf(r)), `a missing migration path named by a feature is one gap: ${gapsOf(r)}`);
++ ok(/README:\d+: names migration 9, which is not in migrations\//.test(gapsOf(r)), `a missing migration named in the README is a gap: ${gapsOf(r)}`);
++ ok(!/app\.js|recorder|example\.com|workers|migration 2\b|migration 11\b/.test(gapsOf(r)), `present paths, other repositories, URLs and non-paths are fine: ${gapsOf(r)}`);
++
++ // consumers: a library's list names every checkout that pins it, at the release it pins (Publishing missed seven).
++ checkout('OpenVibe.Publishing', {
++ 'README.md': README,
++ 'package.json': { name: 'openvibe-publishing', version: '1.4.1', devDependencies: { 'openvibe-contracts': tag('OpenVibe.Contracts', 'v0.9.0') } },
++ 'STATUS.json': status('OpenVibe.Publishing', { version: '1.4.1', consumers: ['OpenVibe.Wiki (v1.3.0; https://openvibe.wiki)', 'OpenVibe.Blog (v1.3.0)', 'OpenVibe.Search (planned)'] }),
++ });
++ const pinsPublishing = (name, v, nested) => checkout(name, { [nested ? 'apps/server/package.json' : 'package.json']: { name: name.toLowerCase(), dependencies: { 'openvibe-publishing': tag('OpenVibe.Publishing', v) } } });
++ pinsPublishing('OpenVibe.Wiki', 'v1.3.0');
++ pinsPublishing('OpenVibe.Blog', 'v1.4.1');
++ pinsPublishing('OpenVibe.Games', 'v1.4.0', true);
++ checkout('OpenVibe.Search', { 'package.json': { name: 'openvibe-search', version: '0.1.0' } });
++ r = checkRepo(facts, service('publishing', 'OpenVibe.Publishing'));
++ ok(/consumers says OpenVibe\.Blog is on v1\.3\.0, it pins v1\.4\.1/.test(gapsOf(r)), `a consumer's stale version is a gap: ${gapsOf(r)}`);
++ ok(/consumers leaves out OpenVibe\.Games, which pins openvibe-publishing v1\.4\.0/.test(gapsOf(r)), `a consumer pinning from a nested app is found: ${gapsOf(r)}`);
++ ok(/consumers names OpenVibe\.Search, which pins no openvibe-publishing/.test(gapsOf(r)), 'a listed repository that pins nothing is a gap');
++ ok(!/Wiki/.test(gapsOf(r)), 'a consumer listed at its release is fine');
++ checkout('OpenVibe.Publishing', {
++ 'README.md': README,
++ 'package.json': { name: 'openvibe-publishing', version: '1.4.1', devDependencies: { 'openvibe-contracts': tag('OpenVibe.Contracts', 'v0.9.0') } },
++ 'STATUS.json': status('OpenVibe.Publishing', { version: '1.4.1', consumers: ['OpenVibe.Wiki (https://openvibe.wiki)', 'OpenVibe.Blog', 'OpenVibe.Games (apps/server)'] }),
++ });
++ r = checkRepo(facts, service('publishing', 'OpenVibe.Publishing'));
++ ok(r.gaps.length === 0, `a consumer list without versions that names every pinning checkout is current: ${gapsOf(r)}`);
++
++ // A checkout with no service manifest: OpenVibe.* needs a STATUS.json; closed ones and other names are skipped.
++ const manifests = [service('lib', 'OpenVibe.Lib')];
++ checkout('OpenVibe.Node', { 'README.md': '# Node\n', 'go.mod': 'module x\n' });
++ checkout('OpenVibe.Realtime', { 'STATUS.json': { repository: 'OpenVibers/OpenVibe.Realtime', stage: 'closed' } });
++ checkout('AFResume', { 'README.md': '# CV\n' });
++ let all = collect(facts, manifests);
++ const byId = (id) => all.find((x) => x.id === id);
++ ok(byId('node') && /STATUS\.json missing/.test(gapsOf(byId('node'))), 'an OpenVibe.* checkout without a STATUS.json is a gap');
++ ok(byId('realtime') && byId('realtime').skipped === 'closed', 'a closed repository is skipped');
++ ok(!all.some((x) => /afresume/i.test(x.id)), 'a checkout not named OpenVibe.* is not the platform\'s');
++ ok(byId('wiki') && byId('wiki').gaps.some((g) => /STATUS\.json missing/.test(g)), 'every unmanifested OpenVibe.* checkout is held to a STATUS.json');
++ checkout('OpenVibe.Node', { 'README.md': '# Node\n', 'STATUS.json': { repository: 'OpenVibers/OpenVibe.Node', stage: 'alpha', version: '0.13.0', deployed: false, features: ['pairing'], updated: '2099-01-01' } });
++ all = collect(facts, manifests, new Set(['node']));
++ ok(all.length === 1 && all[0].id === 'node' && all[0].gaps.length === 0, `--only reaches a repository checkout, and a complete STATUS is current: ${JSON.stringify(all)}`);
++ checkout('OpenVibe.Node', { 'README.md': '# Node\n', 'STATUS.json': { repository: 'OpenVibers/OpenVibe.Node', stage: 'alpha' } });
++ all = collect(facts, manifests, new Set(['node']));
++ ok(all[0].gaps.some((g) => /no "features"/.test(g)) && all[0].gaps.some((g) => /no "deployed"/.test(g)), 'a repository STATUS needs deployed and features');
++ ok(summarize([{ gaps: [] }, { skipped: 'closed', gaps: [] }]) === '1/1 current, 1 closed skipped', 'the summary counts closed repositories apart');
++
++ // Helpers: exact tags only, and path tokens only under the checkout's own top-level directories.
++ ok(exactTag(tag('OpenVibe.SDK', 'v0.60.0')) === 'v0.60.0' && exactTag('1.2.3') === 'v1.2.3' && exactTag('>=0.15.0') === null && exactTag('^1.2.3') === null, 'exactTag reads tags and bare versions, never ranges');
++ const tops = new Set(['server', 'docs']);
++ const found = pathTokens('see server/a.js, docs/x/ and lib/b.js or server/c (server/d.test.js).', tops);
++ ok(JSON.stringify(found) === JSON.stringify(['server/a.js', 'docs/x/', 'server/d.test.js']), `pathTokens: files and directories under own top-level dirs: ${JSON.stringify(found)}`);
++ const mig = migrationMentions('migration 0019 and migrations/0003_x.sql, not migration 2026-10-15');
++ ok(JSON.stringify(mig) === JSON.stringify([19, 3]), `migrationMentions reads numbers, not dates: ${JSON.stringify(mig)}`);
++} finally {
++ fs.rmSync(facts, { recursive: true, force: true });
++}
++
console.log(`docs-currency: ${checks} checks passed`);