openvibers/openvibe.blog Commits
Patch notes: check authored prose, and call a named surface user-facing (#135)
Changes
@@@ -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",@@@ -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"@@@ -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' });@@@ -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 };@@@ -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 };@@@ -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') },
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.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-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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
@@ -1148,9 +1148,9 @@
@@ -1148,9 +1148,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-blog",
"name": "openvibe-blog",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"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",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -27,15 +27,15 @@
@@ -27,15 +27,15 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.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-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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
- "jsonwebtoken": "^9.0.2",
- "@electric-sql/pglite": "^0.5.8"
+ "@electric-sql/pglite": "^0.5.8",
+ "jsonwebtoken": "^9.0.2"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
* Links are checked for shape always; the HTTP check runs when the caller gives verifyLinks (the editor does; a
* 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).
* 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 { hasLooks, isExplicit } = require('./no-looks');
const H2 = /^##\s+(.+)$/gm;
const H2 = /^##\s+(.+)$/gm;
@@ -19,7 +19,6 @@ const LINK = /\[([^\]]*)\]\(([^)\s]+)\)/g;
@@ -19,7 +19,6 @@ const LINK = /\[([^\]]*)\]\(([^)\s]+)\)/g;
const TODO = /\[(TODO|FIXME|TBD)[^\]]*\]/i;
const TODO = /\[(TODO|FIXME|TBD)[^\]]*\]/i;
const countWords = (s) => String(s || '').trim().split(/\s+/).filter(Boolean).length;
const countWords = (s) => String(s || '').trim().split(/\s+/).filter(Boolean).length;
const storyPart = (body) => String(body || '').split(/<details/i)[0];
const storyPart = (body) => String(body || '').split(/<details/i)[0];
-const withoutDetails = (body) => String(body || '').replace(/<details[\s\S]*?<\/details>/gi, ' ');
function headingsOf(body) {
function headingsOf(body) {
const out = [];
const out = [];
@@ -66,7 +65,7 @@ async function evaluate(draft) {
@@ -66,7 +65,7 @@ async function evaluate(draft) {
}
}
warnings.push(...slop.warnings);
warnings.push(...slop.warnings);
// The full list holds exact commit subjects (a "token cap" is not a hat); the story is what describes people.
// 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 (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.');
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' });
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' });
@@ -72,9 +72,19 @@ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -72,9 +72,19 @@ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const BANNED_RE = new RegExp(`(?<![\\w-])(?:${BANNED.map(escapeRe).join('|')})(?![\\w-])`, 'gi');
const BANNED_RE = new RegExp(`(?<![\\w-])(?:${BANNED.map(escapeRe).join('|')})(?![\\w-])`, 'gi');
const BANNED_SET = new Set(BANNED);
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) {
function proseOf(body) {
- return String(body || '')
+ return withoutSourced(body)
.replace(/<details[\s\S]*?<\/details>/gi, ' ')
.replace(/<details[\s\S]*?<\/details>/gi, ' ')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`[^`\n]*`/g, ' ')
.replace(/`[^`\n]*`/g, ' ')
@@ -166,4 +176,4 @@ function scan({ title = '', dek = '', body = '', tags = [], captions = [] } = {}
@@ -166,4 +176,4 @@ function scan({ title = '', dek = '', body = '', tags = [], captions = [] } = {}
return { blocking, warnings };
return { blocking, warnings };
}
}
-module.exports = { scan, phrasesIn, structuresIn, proseOf, BANNED, REWRITES };
+module.exports = { scan, phrasesIn, structuresIn, proseOf, withoutSourced, BANNED, REWRITES };
@@ -58,6 +58,9 @@ const INTENTS = [
@@ -58,6 +58,9 @@ const INTENTS = [
['outbox', /\boutbox\b/i, 'internal'],
['outbox', /\boutbox\b/i, 'internal'],
];
];
const DOCS = /\bin the docs\b|^docs?\b|documentation|readme/i;
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;
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) {
function intentOf(entry) {
@@ -74,7 +77,7 @@ 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 };
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 (CHORE.test(subject)) return { intent: 'chore', kind: 'chore' };
if (entry.major) return { intent: 'other', kind: 'feature' };
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 };
module.exports = { normaliseSubject, intentOf, siteLabel, INTENTS, WORKSTREAM_TRAILER, PLAYER_TRAILER };
@@ -11,6 +11,7 @@ const { seedCard, OFFICIAL_TEXT } = require('../server/patch-notes/voice');
@@ -11,6 +11,7 @@ const { seedCard, OFFICIAL_TEXT } = require('../server/patch-notes/voice');
const { scan } = require('../server/patch-notes/slop');
const { scan } = require('../server/patch-notes/slop');
const { evaluate } = require('../server/patch-notes/review');
const { evaluate } = require('../server/patch-notes/review');
const { hasLooks, noLooks, noLooksTags } = require('../server/patch-notes/no-looks');
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 fixture = require('./fixtures/changelog-2026-10-30.json');
const entry = (service, sha, subject, body = null) => ({
const entry = (service, sha, subject, body = null) => ({
@@ -31,6 +32,19 @@ 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'));
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 () => {
await check('a one-off change is an Also shipped line, never a section shared with unrelated changes', async () => {
const map = build([
const map = build([
{ ...entry('network', '9'.repeat(40), 'The site switcher follows the account') },
{ ...entry('network', '9'.repeat(40), 'The site switcher follows the account') },