openvibers/openvibe.blog Commits

Patch notes: check authored prose, and call a named surface user-facing (#135)

OP OpenVibers committed 14 days ago · 49ebd8355c · 6 files, +41 −15

* Patch notes: only authored prose is checked, and a named surface is user-facing

The first real batch found two false blockers. "Showcase" is the name of a Bench feature,
and a commit subject about a token cap is not a description of a person: the full list was
already exempt, and now so are the two other sourced sections, "Also shipped" (commit
subjects) and "What's next" (PR titles). The story, Under the hood, the title, the dek, tags
and captions are still checked.

A change with no workstream only gets an "Also shipped" line when its subject names something
a reader uses (a page, a switcher, a catalog). An engine port or a generation setting has no
user-visible surface and stays in the full list.

* Pin openvibe-contracts v0.242.1

The CI pin-drift gate fails every pull request while the pin is 20 minors behind
(v0.242.0 has been out for more than 14 days). The contracts APIs Blog uses are unchanged
for it; all 42 test files pass and the drift check is clean with this pin.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex a4df7d9..ca5a577 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.242.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
@@@ -1148,9 +1148,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.222.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
-- "integrity": "sha512-wdUQCilq9glRMwfxEswbIj3l0QQqKDXZAi2+CR5Mlh34rZjI1Q+7GlAoR0UtNwZAgtLwXhPxFyJ/aPk8lcDG1Q==",
++ "version": "0.242.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.242.1",
++ "integrity": "sha512-PjvydO7Z7C2BgwG1vfTzKBwt2WoW0GlLEnmbeW/NqkI3C9KebpcSi+FnQd8bWAmyqrUOTYoevL/uuCGBZ2N4hQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 58a3a27..9980ece 100644
---- a/package.json
++++ b/package.json
@@@ -2,7 +2,7 @@
"name": "openvibe-blog",
"version": "0.1.0",
"private": true,
-- "description": "OpenVibe.Blog \u2014 the official OpenVibe blog and a blog for every member: drafts, immutable revisions, series, tags and categories, scheduling, feeds, sitemaps and Community comments, server-rendered and useful without JavaScript.",
++ "description": "OpenVibe.Blog — the official OpenVibe blog and a blog for every member: drafts, immutable revisions, series, tags and categories, scheduling, feeds, sitemaps and Community comments, server-rendered and useful without JavaScript.",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
@@@ -27,15 +27,15 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.242.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
"pg": "^8.23.0"
},
"devDependencies": {
-- "jsonwebtoken": "^9.0.2",
-- "@electric-sql/pglite": "^0.5.8"
++ "@electric-sql/pglite": "^0.5.8",
++ "jsonwebtoken": "^9.0.2"
},
"engines": {
"node": ">=22.0.0"
ddiff --git a/server/patch-notes/review.js b/server/patch-notes/review.js
iindex 0738c7f..1ef8861 100644
---- a/server/patch-notes/review.js
++++ b/server/patch-notes/review.js
@@@ -11,7 +11,7 @@
* Links are checked for shape always; the HTTP check runs when the caller gives verifyLinks (the editor does; a
* draft made inside the worker does not, so a GitHub hiccup never stops the draft from being filed).
*/
--const { scan } = require('./slop');
++const { scan, proseOf } = require('./slop');
const { hasLooks, isExplicit } = require('./no-looks');
const H2 = /^##\s+(.+)$/gm;
@@@ -19,7 +19,6 @@ const LINK = /\[([^\]]*)\]\(([^)\s]+)\)/g;
const TODO = /\[(TODO|FIXME|TBD)[^\]]*\]/i;
const countWords = (s) => String(s || '').trim().split(/\s+/).filter(Boolean).length;
const storyPart = (body) => String(body || '').split(/<details/i)[0];
--const withoutDetails = (body) => String(body || '').replace(/<details[\s\S]*?<\/details>/gi, ' ');
function headingsOf(body) {
const out = [];
@@@ -66,7 +65,7 @@ async function evaluate(draft) {
}
warnings.push(...slop.warnings);
// The full list holds exact commit subjects (a "token cap" is not a hat); the story is what describes people.
-- const looksText = [title, dek, withoutDetails(body), ...tags, ...captions].join('\n');
++ const looksText = [title, dek, proseOf(body), ...tags, ...captions].join('\n');
if (hasLooks(looksText)) add(blocking, 'looks.person', 'The text describes a person (looks, body, clothing, hair, age or a guessed gender). Public AI text never does. Say "the person" or "the streamer", or remove the sentence.');
if (isExplicit(title, dek, body)) add(blocking, 'looks.explicit', 'The text describes sexual content. It cannot be published.');
checklist.push({ code: 'voice', label: 'The voice card is followed; no banned phrases; no looks language', ok: !blocking.some((b) => /^(slop|looks)\./.test(b.code)), detail: blocking.filter((b) => /^(slop|looks)\./.test(b.code)).length ? `${blocking.filter((b) => /^(slop|looks)\./.test(b.code)).length} blocking` : 'clean' });
ddiff --git a/server/patch-notes/slop.js b/server/patch-notes/slop.js
iindex c3eaa10..06a4a95 100644
---- a/server/patch-notes/slop.js
++++ b/server/patch-notes/slop.js
@@@ -72,9 +72,19 @@ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const BANNED_RE = new RegExp(`(?<![\\w-])(?:${BANNED.map(escapeRe).join('|')})(?![\\w-])`, 'gi');
const BANNED_SET = new Set(BANNED);
--/** The prose of a Markdown body: the full list, fenced code, inline code, link URLs and HTML are not prose. */
++/**
++ * The sections that quote someone else: the full list, "Also shipped" (commit subjects) and "What's next"
++ * (PR titles). A product name in them ("Showcase S2", "a token cap") is not the writer's filler.
++ */
++function withoutSourced(text) {
++ return String(text || '')
++ .replace(/^##\s+Also shipped[\s\S]*?(?=^##\s|(?![\s\S]))/gim, ' ')
++ .replace(/^##\s+What's next[\s\S]*?(?=^##\s|(?![\s\S]))/gim, ' ');
++}
++
++/** The prose of a Markdown body: sourced sections, fenced code, inline code, link URLs and HTML are not prose. */
function proseOf(body) {
-- return String(body || '')
++ return withoutSourced(body)
.replace(/<details[\s\S]*?<\/details>/gi, ' ')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`[^`\n]*`/g, ' ')
@@@ -166,4 +176,4 @@ function scan({ title = '', dek = '', body = '', tags = [], captions = [] } = {}
return { blocking, warnings };
}
--module.exports = { scan, phrasesIn, structuresIn, proseOf, BANNED, REWRITES };
++module.exports = { scan, phrasesIn, structuresIn, proseOf, withoutSourced, BANNED, REWRITES };
ddiff --git a/server/patch-notes/text.js b/server/patch-notes/text.js
iindex 041bfde..e9684fa 100644
---- a/server/patch-notes/text.js
++++ b/server/patch-notes/text.js
@@@ -58,6 +58,9 @@ const INTENTS = [
['outbox', /\boutbox\b/i, 'internal'],
];
const DOCS = /\bin the docs\b|^docs?\b|documentation|readme/i;
++// A change with no workstream is only "also shipped" when it names something a reader uses. An engine
++// port or a generation setting is internal; it stays in the full list, not in the prose.
++const USER_FACING = /\b(home|page|site switcher|switcher|launcher|catalog|search|menu|button|layout|phone|mobile|account|profile|settings|reader|visitor|upload|editor|dashboard|console|notifications?|prices?|cart|checkout)\b/i;
const CHORE = /^openvibe-(?:shared|sdk|contracts|publishing)\s+v?\d|^(?:pin|bump)\b|\blockfiles?\b|package\.json|dependenc|^tidy the docs\b|^update the docs\b/i;
function intentOf(entry) {
@@@ -74,7 +77,7 @@ function intentOf(entry) {
for (const [key, re, kind] of INTENTS) if (re.test(subject)) return { intent: key, kind };
if (CHORE.test(subject)) return { intent: 'chore', kind: 'chore' };
if (entry.major) return { intent: 'other', kind: 'feature' };
-- return { intent: 'other', kind: /^add\b|\bnew\b|\bnow\b|support/i.test(subject) ? 'feature' : 'fix' };
++ return { intent: 'other', kind: USER_FACING.test(subject) ? 'feature' : 'internal' };
}
module.exports = { normaliseSubject, intentOf, siteLabel, INTENTS, WORKSTREAM_TRAILER, PLAYER_TRAILER };
ddiff --git a/test/patch-notes.test.js b/test/patch-notes.test.js
iindex 28f58a0..72e50f0 100644
---- a/test/patch-notes.test.js
++++ b/test/patch-notes.test.js
@@@ -11,6 +11,7 @@ const { seedCard, OFFICIAL_TEXT } = require('../server/patch-notes/voice');
const { scan } = require('../server/patch-notes/slop');
const { evaluate } = require('../server/patch-notes/review');
const { hasLooks, noLooks, noLooksTags } = require('../server/patch-notes/no-looks');
++const { intentOf } = require('../server/patch-notes/text');
const fixture = require('./fixtures/changelog-2026-10-30.json');
const entry = (service, sha, subject, body = null) => ({
@@@ -31,6 +32,19 @@ const entry = (service, sha, subject, body = null) => ({
assert.ok(map.stories.every((s) => s.kind === 'fix' || s.kind === 'feature'));
});
++ await check('a one-off change is user-facing only when it names something a reader uses', async () => {
++ assert.strictEqual(intentOf({ service: 'network', repo: 'OpenVibers/OpenVibe.Network', subject: 'The site switcher follows the account (#430)' }).kind, 'feature');
++ assert.strictEqual(intentOf({ service: 'bench', repo: 'OpenVibers/OpenVibe.Bench', subject: 'Generation: a token cap per showcase kind (#17)' }).kind, 'internal');
++ assert.strictEqual(intentOf({ service: 'codes', repo: 'OpenVibers/OpenVibe.Codes', subject: 'engine/: the agent engine, ported from the portal (C1) (#159)' }).kind, 'internal');
++ });
++
++ await check('a product name in a sourced line is not the writer\'s slop; the story still is', async () => {
++ const sourced = scan({ body: "## What's next\n\n- **Next:** Showcase S2: seed rounds for the ten showcase tasks ([#19](https://github.com/OpenVibers/OpenVibe.Bench/pull/19)).\n\n## Also shipped\n\n- **Bench:** showcase: real outputs from two models.\n" });
++ assert.strictEqual(sourced.blocking.length, 0);
++ const story = scan({ body: '## A section\n\nWe showcase the new button.' });
++ assert.ok(story.blocking.some((b) => b.phrase === 'showcase'));
++ });
++
await check('a one-off change is an Also shipped line, never a section shared with unrelated changes', async () => {
const map = build([
{ ...entry('network', '9'.repeat(40), 'The site switcher follows the account') },