Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions SHARED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <reason>`. 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
Expand Down
20 changes: 19 additions & 1 deletion scripts/ci/experience-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: <reason>` 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:
*
Expand Down Expand Up @@ -52,6 +58,7 @@ export const KINDS = [
'localComposers',
'localComments',
'localArticleEditors',
'mutedChats',
];

const SKIP_DIRS = new Set([
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
}

Expand All @@ -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.`,
);
Expand Down
26 changes: 26 additions & 0 deletions scripts/ci/experience-audit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => <ChatApp composer={{ attach: false }} />;'), ['mutedChats']);
assert.deepEqual(classify('src/Chat.tsx', chat + 'export const C = () => <ChatApp composer={{ voice: false }} />;'), ['mutedChats']);
assert.deepEqual(
classify('src/Chat.tsx', chat + 'export const C = () => <ChatApp composer={{ attach: {} , voice: {} }} />;'),
[],
);
assert.deepEqual(
classify(
'src/Chat.tsx',
chat + '<ChatApp\n composer={{ attach: false }} // experience-exempt: read-only archive, nothing can be sent\n/>',
),
[],
);
assert.deepEqual(classify('src/Other.tsx', 'const o = { attach: false };\n<ChatApp />'), []);
});

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/);
});
Loading