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)
Changes
@@@ -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 isCallers are tiered: anonymous < session < user < app/service. Quotas count each tool's `cost` withinits `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 cannotexpress, 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);@@@ -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)",@@@ -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,++};@@@ -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,@@@ -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), , [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`);
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Machine-readable contracts for the whole OpenVibe network.
> 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).
**Plan:** OpenVibe Development Roadmap, Wave 1 (implementation plan rev 3, §3.1 and §18.11).
**License:** AGPL-3.0 (same as every OpenVibe service).
**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
@@ -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) |
| `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>` |
| `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_`.
**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
@@ -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
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
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
`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
`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
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
@@ -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`.
`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 |
| Route | Capability | Body | Answer |
| --- | --- | --- | --- |
| --- | --- | --- | --- |
@@ -322,9 +323,9 @@ consumers pinning the previous tag; a published tag is never moved.
@@ -322,9 +323,9 @@ consumers pinning the previous tag; a published tag is never moved.
## Launch rule
## 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;
1. an owning runtime with health/readiness endpoints and observability;
2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
@@ -11,20 +11,20 @@
@@ -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",
"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)",
"contracts": "openvibe-contracts (this package: package.json's version, released as the matching tag)",
"features": [
"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",
"13 user-module namespace manifests",
"validate, ids, http (problem+json, trace and idempotency middleware), capabilities.check and serviceAuth",
"validate, ids, http (problem+json, trace and idempotency middleware), capabilities.check and serviceAuth",
"openvibe-contracts-check for service CI",
"openvibe-contracts-check for service CI",
"generated TypeScript types, JSON Schema bundle and per-service OpenAPI 3.1",
"generated TypeScript types, JSON Schema bundle and per-service OpenAPI 3.1",
"compatibility check against the previous tag (scripts/compat.js)",
"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)",
"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.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",
"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",
"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",
"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)",
"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)",
@@ -22,7 +22,34 @@
@@ -22,7 +22,34 @@
* `contracts` names the openvibe-contracts tag package.json pins; `updated` is no older than the newest commit that
* `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.
* 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 fs = require('fs');
const path = require('path');
const path = require('path');
@@ -39,6 +66,22 @@ const REQUIRED = [
@@ -39,6 +66,22 @@ const REQUIRED = [
['deploy', /^deploy|^deployment\b|^running it in production\b|^production\b/i],
['deploy', /^deploy|^deployment\b|^running it in production\b|^production\b/i],
];
];
const STATUS_FIELDS = ['repository', 'stage', 'deployed', 'contracts', 'features', 'updated'];
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) {
function headings(readme) {
return readme.split('\n').filter((l) => /^##\s+/.test(l)).map((l) => l.replace(/^##\s+/, '').replace(/[`*_]/g, '').trim());
return readme.split('\n').filter((l) => /^##\s+/.test(l)).map((l) => l.replace(/^##\s+/, '').replace(/[`*_]/g, '').trim());
@@ -52,6 +95,159 @@ function contractsPin(pkg) {
@@ -52,6 +95,159 @@ function contractsPin(pkg) {
return m ? (m[1].startsWith('v') ? m[1] : `v${m[1]}`) : spec;
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) {
function lastPinChange(dir) {
try {
try {
const out = execFileSync('git', ['-C', dir, 'log', '-1', '--format=%cI', '--', 'package.json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
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) {
@@ -59,7 +255,149 @@ function lastPinChange(dir) {
} catch { return null; }
} 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 name = String(manifest.repository || '').split('/').pop();
const dir = path.join(root, name);
const dir = path.join(root, name);
const gaps = [];
const gaps = [];
@@ -72,6 +410,7 @@ function checkRepo(root, manifest) {
@@ -72,6 +410,7 @@ function checkRepo(root, manifest) {
if (manifest.status === 'placeholder') return { id: manifest.id, repository: manifest.repository, skipped: 'placeholder', gaps: [] };
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'] };
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');
const readmePath = path.join(dir, 'README.md');
if (!fs.existsSync(readmePath)) gaps.push('README.md missing');
if (!fs.existsSync(readmePath)) gaps.push('README.md missing');
else {
else {
@@ -94,31 +433,67 @@ function checkRepo(root, manifest) {
@@ -94,31 +433,67 @@ function checkRepo(root, manifest) {
const changed = lastPinChange(dir);
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}`);
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 };
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. */
/** The summary line: skipped placeholders and retired services are counted apart from the checkouts actually checked. */
function summarize(results) {
function summarize(results) {
const placeholders = results.filter((r) => r.skipped === 'placeholder').length;
const placeholders = results.filter((r) => r.skipped === 'placeholder').length;
const retired = results.filter((r) => r.skipped === 'retired').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;
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) {
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); }
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 onlyArg = argv.indexOf('--only');
const only = onlyArg >= 0 ? new Set(String(argv[onlyArg + 1] || '').split(',').filter(Boolean)) : null;
const only = onlyArg >= 0 ? new Set(String(argv[onlyArg + 1] || '').split(',').filter(Boolean)) : null;
const dir = path.join(__dirname, '..', 'manifests', 'services');
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')))
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));
if (argv.includes('--json')) console.log(JSON.stringify(results, null, 2));
else {
else {
for (const r of results) {
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'}`);
else console.log(`${r.gaps.length ? '✗' : '✓'} ${r.id.padEnd(12)} ${r.gaps.length ? r.gaps.join('; ') : 'current'}`);
}
}
console.log(summarize(results));
console.log(summarize(results));
@@ -127,4 +502,7 @@ function main(argv) {
@@ -127,4 +502,7 @@ function main(argv) {
}
}
if (require.main === module) main(process.argv.slice(2));
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,
+};
@@ -11,8 +11,11 @@
@@ -11,8 +11,11 @@
*
*
* A checkout is a directory under the root holding `.git`, `package.json` or `STATUS.json`. For each one:
* 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
* migrations/ the count and the dialect the SQL declares
* this repository's manifests/services|products/*.json the service or product the checkout is
* this repository's manifests/services|products/*.json the service or product the checkout is
*
*
@@ -106,8 +109,36 @@ function pinTag(text, name) {
@@ -106,8 +109,36 @@ function pinTag(text, name) {
return m ? (m[1].startsWith('v') ? m[1] : `v${m[1]}`) : null;
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);
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]) {
if (status) for (const name of PIN_NAMES) if (!pins[name]) {
const tag = pinTag(`${status.contracts || ''} ${status.sdk || ''}`, name);
const tag = pinTag(`${status.contracts || ''} ${status.sdk || ''}`, name);
if (tag) pins[name] = tag;
if (tag) pins[name] = tag;
@@ -133,13 +164,13 @@ function recordFor(name, dir, index) {
@@ -133,13 +164,13 @@ function recordFor(name, dir, index) {
service: service ? service.id : null,
service: service ? service.id : null,
product: service ? ((service.site && service.site.name) || service.name) : (product ? product.name : (status && status.product) || null),
product: service ? ((service.site && service.site.name) || service.name) : (product ? product.name : (status && status.product) || null),
stage: (status && status.stage) || null,
stage: (status && status.stage) || null,
- version: (pkg && pkg.version) || null,
+ version: (pkg && pkg.version) || (status && typeof status.version === 'string' ? status.version : null),
runtime,
runtime,
database: databaseOf(dir, status, pkg, exposure),
database: databaseOf(dir, status, pkg, exposure),
migrations: migrationFiles(dir).length,
migrations: migrationFiles(dir).length,
authority: service ? ((service.namespacesOwned || []).length ? service.namespacesOwned.slice() : [service.id]) : [],
authority: service ? ((service.namespacesOwned || []).length ? service.namespacesOwned.slice() : [service.id]) : [],
domains: domainsOf(status, manifest),
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.
// 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,
deployed: exposure === 'retired' ? false : status ? (status.deployed === undefined ? null : status.deployed) : null,
exposure,
exposure,
@@ -6,7 +6,7 @@ const assert = require('assert');
@@ -6,7 +6,7 @@ const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
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;
let checks = 0;
const ok = (cond, msg) => { assert.ok(cond, msg); checks++; };
const ok = (cond, msg) => { assert.ok(cond, msg); checks++; };
@@ -57,4 +57,163 @@ try {
@@ -57,4 +57,163 @@ try {
} finally {
} finally {
fs.rmSync(root, { recursive: true, force: true });
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), , [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`);
console.log(`docs-currency: ${checks} checks passed`);