diff --git a/SHARED.md b/SHARED.md index 6802fa2..94deb4c 100644 --- a/SHARED.md +++ b/SHARED.md @@ -557,6 +557,13 @@ is for): | causius | 4 | 1 | 0 | 0 | 0 | | diplodoctor | 2 | 1 | 0 | 0 | 0 | +The audit also reports `mutedChats` (2026-11-11): a file that renders chatkit's +`ChatApp` or `Composer` with `attach: false` or `voice: false` on a line with no +`experience-exempt: `. A chat with no paperclip or no microphone cannot +be used by everyone it serves (Solon's law chat shipped with both off). It is +opt-in: it fails a product only when its ceiling names it, e.g. +`"mutedChats": 0`, so no product's CI changes until it asks. + BiasLens's one `localComposers` file is the article composer it shipped in November 2026 as app code; it moves into `writingkit` in the plan, which is the case the rule exists for. `localComments` reads 0 across the fleet because diff --git a/scripts/ci/experience-audit.mjs b/scripts/ci/experience-audit.mjs index d930ca2..534c521 100644 --- a/scripts/ci/experience-audit.mjs +++ b/scripts/ci/experience-audit.mjs @@ -24,6 +24,12 @@ * import chatkit * localArticleEditors an Article/Post/Blog Composer/Editor/Form file that * does not import writingkit + * mutedChats a file that renders chatkit's ChatApp/Composer with + * `attach: false` or `voice: false` on a line that carries + * no `experience-exempt: ` comment. A chat with no + * paperclip or no microphone is a chat that cannot be + * used by everyone it serves. Opt-in: it only fails a + * product whose ceiling names it (`"mutedChats": 0`). * * What it does with the counts is the product's choice, in package-plan.json: * @@ -52,6 +58,7 @@ export const KINDS = [ 'localComposers', 'localComments', 'localArticleEditors', + 'mutedChats', ]; const SKIP_DIRS = new Set([ @@ -80,6 +87,9 @@ const ARTICLE_EDITOR = /^(Article|Post|Blog)(Composer|Editor|Form)\.(tsx|jsx)$/; const imports = (source, pkg) => new RegExp(`(?:from\\s*|import\\s*)['"]${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:/[^'"]*)?['"]`).test(source); +const MUTED = /\b(attach|voice)\s*(?::|=)\s*\{?\s*false\b/; +const LINE_EXEMPT = /experience-exempt:\s*\S.{19,}/; + const EXEMPT = /^\s*(?:\/\/|\/\*)\s*experience-exempt:\s*(\S.{19,})/; /** Walk a repo's source files, skipping build output and tests. */ @@ -112,6 +122,12 @@ export function classify(path, source) { if (COMPOSER.test(name) && !chatkit) kinds.push('localComposers'); if (COMMENTS.test(name) && !chatkit) kinds.push('localComments'); if (ARTICLE_EDITOR.test(name) && !writingkit) kinds.push('localArticleEditors'); + if ( + chatkit && + /<(ChatApp|Composer)\b/.test(source) && + source.split('\n').some((line) => MUTED.test(line) && !LINE_EXEMPT.test(line)) + ) + kinds.push('mutedChats'); return kinds; } @@ -138,7 +154,9 @@ export function measure(dir) { export function overCeiling(counts, plan) { const ceiling = plan?.experience?.ceiling; if (!ceiling || typeof ceiling !== 'object') return []; - return KINDS.filter((k) => counts[k] > (Number.isInteger(ceiling[k]) ? ceiling[k] : 0)).map( + // mutedChats is opt-in: a product that has not named it in its ceiling is not + // failed for it (existing products have a baseline they never agreed to). + return KINDS.filter((k) => (k !== 'mutedChats' || Number.isInteger(ceiling[k])) && counts[k] > (Number.isInteger(ceiling[k]) ? ceiling[k] : 0)).map( (k) => `${k}: ${counts[k]} above the ceiling of ${Number.isInteger(ceiling[k]) ? ceiling[k] : 0} — use the package's component (see AGENTS.md "The experience belongs to the package"), or lower the count; do not raise the ceiling.`, ); diff --git a/scripts/ci/experience-audit.test.mjs b/scripts/ci/experience-audit.test.mjs index e5142ac..f725559 100644 --- a/scripts/ci/experience-audit.test.mjs +++ b/scripts/ci/experience-audit.test.mjs @@ -74,3 +74,29 @@ test('a ceiling holds the count; no ceiling only reports; a missing kind means z assert.match(over[0], /bareTextareas: 3 above the ceiling of 2/); assert.match(over[1], /localChrome: 1 above the ceiling of 0/); }); + +test('a chat with attachments or voice switched off needs a reason on that line', () => { + const chat = "import { ChatApp } from '@bitbaum/chatkit/react';\n"; + assert.deepEqual(classify('src/Chat.tsx', chat + 'export const C = () => ;'), ['mutedChats']); + assert.deepEqual(classify('src/Chat.tsx', chat + 'export const C = () => ;'), ['mutedChats']); + assert.deepEqual( + classify('src/Chat.tsx', chat + 'export const C = () => ;'), + [], + ); + assert.deepEqual( + classify( + 'src/Chat.tsx', + chat + '', + ), + [], + ); + assert.deepEqual(classify('src/Other.tsx', 'const o = { attach: false };\n'), []); +}); + +test('mutedChats fails only a product whose ceiling names it', () => { + const counts = { bareTextareas: 0, localChrome: 0, localComposers: 0, localComments: 0, localArticleEditors: 0, mutedChats: 2 }; + assert.deepEqual(overCeiling(counts, { experience: { ceiling: {} } }), []); + const over = overCeiling(counts, { experience: { ceiling: { mutedChats: 0 } } }); + assert.equal(over.length, 1); + assert.match(over[0], /mutedChats: 2 above the ceiling of 0/); +});