openvibers/openvibe.shared Commits

docs-status reads a workspace root's packages (npm/pnpm workspaces, else apps/* and packages/*) and every library a STATUS field names, so pin rounds keep Games, Tools and Examples current (#130)

OP OpenVibers committed 17 days ago · 9521372e9a · 2 files, +121 −6

Changes

ddiff --git a/scripts/docs-status.js b/scripts/docs-status.js
iindex 27f669d..aeae0d7 100644
---- a/scripts/docs-status.js
++++ b/scripts/docs-status.js
@@@ -53,11 +53,58 @@ function pinsIn(pkg) {
return out;
}
--/** The root package.json's pins, or {} when it is absent or unreadable. */
++/**
++ * The workspace packages' directories: the root package.json's `workspaces` (an array, or { packages }) and
++ * pnpm-workspace.yaml's `packages`, with a trailing `/*` read one level deep. Negations and `**` are skipped.
++ */
++function workspaceDirs(dir) {
++ const patterns = [];
++ const root = readJson(path.join(dir, 'package.json'));
++ const ws = root && (Array.isArray(root.workspaces) ? root.workspaces : root.workspaces && root.workspaces.packages);
++ if (Array.isArray(ws)) patterns.push(...ws);
++ const pnpm = path.join(dir, 'pnpm-workspace.yaml');
++ if (fs.existsSync(pnpm)) {
++ for (const m of fs.readFileSync(pnpm, 'utf8').matchAll(/^\s*-\s*['"]?([^'"\n#]+?)['"]?\s*$/gm)) patterns.push(m[1]);
++ }
++ // No declared workspaces (Tools keeps one package per app): the conventional folders.
++ if (!patterns.length) patterns.push('apps/*', 'packages/*');
++ const out = [];
++ for (const pattern of patterns) {
++ if (typeof pattern !== 'string' || pattern.startsWith('!') || pattern.includes('**')) continue;
++ if (pattern.endsWith('/*')) {
++ const base = path.join(dir, pattern.slice(0, -2));
++ try {
++ for (const e of fs.readdirSync(base, { withFileTypes: true })) if (e.isDirectory()) out.push(path.join(base, e.name));
++ } catch { /* no such folder */ }
++ } else out.push(path.join(dir, pattern));
++ }
++ return out;
++}
++
++const newer = (a, b) => {
++ const x = a.split('.').map(Number), y = b.split('.').map(Number);
++ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
++ return false;
++};
++
++/**
++ * The repository's pins: its workspace packages' (the newest of each library, since a pin round moves them together)
++ * with the root package.json's own over them. A workspace root that pins nothing itself (Games, Tools, Examples) used
++ * to read as "pins nothing", so its STATUS.json kept naming old releases after every pin round.
++ */
function pinsFor(dir) {
-- const file = path.join(dir, 'package.json');
-- if (!fs.existsSync(file)) return {};
-- try { return pinsIn(JSON.parse(fs.readFileSync(file, 'utf8'))); } catch { return {}; }
++ const out = {};
++ for (const d of workspaceDirs(dir)) {
++ for (const [name, version] of Object.entries(pinsIn(readJson(path.join(d, 'package.json'))))) {
++ if (!out[name] || newer(version, out[name])) out[name] = version;
++ }
++ }
++ return Object.assign(out, pinsIn(readJson(path.join(dir, 'package.json'))));
++}
++
++/** Every "openvibe-<lib> vX.Y.Z" a STATUS string names, moved to that library's pin (one field may name several). */
++function withNamedPins(text, pins) {
++ return String(text).replace(/\b(openvibe-[a-z]+) v(\d+\.\d+\.\d+)/g, (all, name) => (pins[name] ? `${name} v${pins[name]}` : all));
}
/** The status value for a pin, keeping prose after the version: "openvibe-contracts v0.61.0 (devDependency…)". */
@@@ -67,6 +114,12 @@ function statusValue(existing, name, version) {
return existing.replace(VERSION, version);
}
++/** Whether another tracked STATUS field names this library ("…, openvibe-sdk v0.65.0, …" in `contracts`). */
++function namedElsewhere(status, lib) {
++ const re = new RegExp(`\\b${escapeRe(lib.pkg)} v\\d+\\.\\d+\\.\\d+`);
++ return LIBS.some((l) => l.field && l.field !== lib.field && typeof status[l.field] === 'string' && re.test(status[l.field]));
++}
++
/** STATUS.json findings for every tracked pin. */
function statusFindings(status, pins) {
const out = [];
@@@ -74,6 +127,7 @@ function statusFindings(status, pins) {
if (!lib.field || !(lib.pkg in pins)) continue;
const want = pins[lib.pkg];
const existing = status[lib.field];
++ if (existing === undefined && namedElsewhere(status, lib)) continue; // checked with the field that names it
if (typeof existing !== 'string' || !VERSION.test(existing)) {
out.push({ ok: false, file: 'STATUS.json', field: lib.field, reason: `pins ${lib.pkg} v${want} but STATUS.json has no ${lib.field} version` });
continue;
@@@ -82,6 +136,15 @@ function statusFindings(status, pins) {
if (have !== want) out.push({ ok: false, file: 'STATUS.json', field: lib.field, reason: `pins ${lib.pkg} v${want} but STATUS.json ${lib.field} says v${have}` });
else out.push({ ok: true, file: 'STATUS.json', field: lib.field, reason: `${lib.pkg} v${want}` });
}
++ // Other libraries a tracked field names ("openvibe-contracts v0.214.0, openvibe-sdk v0.65.0, …" in one field).
++ for (const field of LIBS.filter((l) => l.field).map((l) => l.field)) {
++ if (typeof status[field] !== 'string') continue;
++ for (const [, name, have] of status[field].matchAll(/\b(openvibe-[a-z]+) v(\d+\.\d+\.\d+)/g)) {
++ if (pins[name] && pins[name] !== have && !(byPackage.get(name) && byPackage.get(name).field === field)) {
++ out.push({ ok: false, file: 'STATUS.json', field, reason: `pins ${name} v${pins[name]} but STATUS.json ${field} names v${have}` });
++ }
++ }
++ }
return out;
}
@@@ -151,8 +214,15 @@ function withStatusFields(status, pins, today) {
const tracked = LIBS.filter((l) => l.field).map((l) => l.field);
const updates = {};
for (const lib of LIBS) {
-- if (lib.field && pins[lib.pkg]) updates[lib.field] = statusValue(status[lib.field], lib.pkg, pins[lib.pkg]);
++ if (lib.field && pins[lib.pkg] && !(status[lib.field] === undefined && namedElsewhere(status, lib))) {
++ updates[lib.field] = statusValue(status[lib.field], lib.pkg, pins[lib.pkg]);
++ }
++ }
++ for (const field of tracked) {
++ const value = updates[field] !== undefined ? updates[field] : status[field];
++ if (typeof value === 'string') updates[field] = withNamedPins(value, pins);
}
++ for (const field of tracked) if (status[field] === undefined && updates[field] === undefined) delete updates[field];
const out = {};
let placed = false;
for (const [key, value] of Object.entries(status)) {
@@@ -203,7 +273,7 @@ function main(dir = '.', { check = false } = {}) {
return { ...inspect(dir), changed };
}
--module.exports = { LIBS, START, END, pinsIn, pinsFor, statusValue, statusFindings, versionsInner, versionsBlock, applyBlock, readmeFindings, inspect, withStatusFields, update, main };
++module.exports = { LIBS, START, END, pinsIn, pinsFor, workspaceDirs, withNamedPins, statusValue, statusFindings, versionsInner, versionsBlock, applyBlock, readmeFindings, inspect, withStatusFields, update, main };
if (require.main === module || !module.parent) {
const args = process.argv.slice(2);
ddiff --git a/test/docs-status.test.js b/test/docs-status.test.js
iindex 8456679..660a4c7 100644
---- a/test/docs-status.test.js
++++ b/test/docs-status.test.js
@@@ -150,4 +150,49 @@ assert.strictEqual(applyBlock('a\n\n<!-- versions:start -->\n- openvibe-sdk: v0.
assert.deepStrictEqual(update(dir, { today: '2026-10-10' }), [], 'a rerun writes nothing');
fs.rmSync(dir, { recursive: true, force: true });
}
++
++// A workspace root that pins nothing itself: its packages' pins are the repository's (Games' apps/server, Tools'
++// apps/*, Examples' examples/*), and one STATUS field may name several libraries (2026-10-27: three repositories kept
++// naming openvibe-contracts v0.214.0 after the v0.214.1 pin round because the root read as "pins nothing").
++{
++ const pkg = (deps) => JSON.stringify({ name: 'x', private: true, dependencies: deps });
++ // npm workspaces (Examples): examples/* packages; the newest pin of a library wins.
++ const npmRoot = tmp('ws-npm');
++ write(npmRoot, 'package.json', JSON.stringify({ name: 'root', private: true, workspaces: ['examples/*'] }));
++ for (const [name, v] of [['a', 'v0.214.1'], ['b', 'v0.214.0']]) {
++ fs.mkdirSync(path.join(npmRoot, 'examples', name), { recursive: true });
++ write(path.join(npmRoot, 'examples', name), 'package.json', pkg({ 'openvibe-contracts': url('OpenVibe.Contracts', v) }));
++ }
++ assert.deepStrictEqual(pinsFor(npmRoot), { 'openvibe-contracts': '0.214.1' });
++ // pnpm (Games): pnpm-workspace.yaml; a composite `contracts` field names every library and the sdk/shared fields
++ // are not demanded beside it; update moves each named version and keeps the prose.
++ const pnpmRoot = tmp('ws-pnpm');
++ write(pnpmRoot, 'package.json', JSON.stringify({ name: 'games', private: true }));
++ write(pnpmRoot, 'pnpm-workspace.yaml', "packages:\n - 'apps/*'\n - 'packages/*'\n");
++ fs.mkdirSync(path.join(pnpmRoot, 'apps', 'server'), { recursive: true });
++ write(path.join(pnpmRoot, 'apps', 'server'), 'package.json', pkg({
++ 'openvibe-contracts': url('OpenVibe.Contracts', 'v0.214.1'), 'openvibe-sdk': url('OpenVibe.SDK', 'v0.65.0'), 'openvibe-shared': url('OpenVibe.Shared', 'v4.15.2'),
++ }));
++ write(pnpmRoot, 'STATUS.json', JSON.stringify({ repository: 'OpenVibers/OpenVibe.Games',
++ contracts: 'openvibe-contracts v0.214.0, openvibe-sdk v0.62.0, openvibe-shared v4.9.0 (apps/server/package.json)', updated: '2026-10-01' }, null, 2) + '\n');
++ const before = inspect(pnpmRoot).findings.filter((f) => !f.ok).map((f) => f.reason);
++ assert.ok(before.some((r) => /contracts says v0\.214\.0/.test(r)) && before.some((r) => /names v0\.62\.0/.test(r)) && before.some((r) => /names v4\.9\.0/.test(r)), before.join(' | '));
++ assert.ok(!before.some((r) => /has no (sdk|shared) version/.test(r)), 'a library named in the contracts field is not missing its own field');
++ update(pnpmRoot, { today: '2026-10-27' });
++ const st = JSON.parse(read(pnpmRoot, 'STATUS.json'));
++ assert.strictEqual(st.contracts, 'openvibe-contracts v0.214.1, openvibe-sdk v0.65.0, openvibe-shared v4.15.2 (apps/server/package.json)');
++ assert.ok(!('sdk' in st) && !('shared' in st), 'no duplicate fields added');
++ assert.strictEqual(st.updated, '2026-10-27');
++ assert.deepStrictEqual(inspect(pnpmRoot).findings.filter((f) => !f.ok), []);
++ // No declared workspaces (Tools): the conventional apps/* folders.
++ const appsRoot = tmp('ws-apps');
++ write(appsRoot, 'package.json', JSON.stringify({ name: 'tools', private: true }));
++ fs.mkdirSync(path.join(appsRoot, 'apps', 'gateway'), { recursive: true });
++ write(path.join(appsRoot, 'apps', 'gateway'), 'package.json', pkg({ 'openvibe-shared': url('OpenVibe.Shared', 'v4.15.2') }));
++ assert.deepStrictEqual(pinsFor(appsRoot), { 'openvibe-shared': '4.15.2' });
++ // The root's own pins still win over its packages'.
++ write(appsRoot, 'package.json', JSON.stringify({ name: 'tools', private: true, dependencies: { 'openvibe-shared': url('OpenVibe.Shared', 'v4.15.1') } }));
++ assert.deepStrictEqual(pinsFor(appsRoot), { 'openvibe-shared': '4.15.1' });
++ for (const d of [npmRoot, pnpmRoot, appsRoot]) fs.rmSync(d, { recursive: true, force: true });
++}
console.log('docs-status: all checks passed');