diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..84b6f10 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Bulk reformats. `git config blame.ignoreRevsFile .git-blame-ignore-revs` +6e12c84d059bb4f261a25a27a57c44e58f658bca # prettier, 12 files diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a646416..554fa37 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,7 +11,7 @@ version: 2 updates: - package-ecosystem: npm - directory: "/" + directory: '/' schedule: interval: weekly open-pull-requests-limit: 5 @@ -20,7 +20,7 @@ updates: update-types: [minor, patch] - package-ecosystem: github-actions - directory: "/" + directory: '/' schedule: interval: weekly open-pull-requests-limit: 3 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b698663 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,31 @@ +# Build output and vendored trees — formatting these is noise. +node_modules +.next +dist +build +out +coverage +.turbo +.vercel +*.min.js +*.min.css + +# Generated during a build, so it is absent locally and present in CI — which +# makes a clean local --check no evidence at all. Contentlayer's output also +# uses import assertions, which prettier's parser rejects outright. +.contentlayer +.astro +.svelte-kit +storybook-static +test-results +playwright-report + +# Lockfiles are generated; prettier would rewrite them wholesale. +package-lock.json +pnpm-lock.yaml +yarn.lock + +# Markdown is deliberately out of scope for now. Prettier rewraps prose, which +# is where it is most opinionated and least useful, and it would bury the real +# diff. Remove this line when you want docs formatted too. +*.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..616247a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "trailingComma": "all", + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 25dc93d..428e6f2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,9 +2,9 @@ // library code, and a bespoke rule set would be a second opinion to maintain // for no benefit. The floor is "lint runs and can fail", not "lint encodes // taste". -import js from '@eslint/js' -import globals from 'globals' -import tseslint from 'typescript-eslint' +import js from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; export default tseslint.config( { @@ -22,4 +22,4 @@ export default tseslint.config( files: ['test/**/*.js', 'scripts/**/*.{js,mjs}'], languageOptions: { globals: { ...globals.node, ...globals.nodeBuiltin } }, }, -) +); diff --git a/package-lock.json b/package-lock.json index e1e72fb..5fd5a26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,12 @@ "@types/node": "^22.10.2", "eslint": "^9.39.5", "globals": "^15.15.0", + "prettier": "3.9.6", "typescript": "^5.8.2", "typescript-eslint": "^8.67.0" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@eslint-community/eslint-utils": { @@ -1308,6 +1309,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index 1ebec7d..a192faa 100644 --- a/package.json +++ b/package.json @@ -44,14 +44,17 @@ "lint": "eslint .", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "node --test test/*.test.js", - "verify": "npm run lint && npm run typecheck && npm run build && npm test", - "prepare": "npm run build" + "verify": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm test", + "prepare": "npm run build", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "devDependencies": { "@eslint/js": "^9.39.5", "@types/node": "^22.10.2", "eslint": "^9.39.5", "globals": "^15.15.0", + "prettier": "3.9.6", "typescript": "^5.8.2", "typescript-eslint": "^8.67.0" } diff --git a/src/ai.ts b/src/ai.ts index 03996c6..d75a277 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -67,11 +67,11 @@ export function whenMentioned(aliases: readonly string[]): RespondPolicy { // addressed to it. const patterns = aliases .filter(Boolean) - .map(a => new RegExp(`(^|\\W)${a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\W|$)`, 'i')); + .map((a) => new RegExp(`(^|\\W)${a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\W|$)`, 'i')); return ({ visible, self }) => { const last = visible[visible.length - 1]; if (!last || last.authorId === self.actorId) return false; - return patterns.some(re => re.test(last.body)); + return patterns.some((re) => re.test(last.body)); }; } @@ -84,7 +84,7 @@ export function whenMentioned(aliases: readonly string[]): RespondPolicy { * clinical thread unusable the moment an assistant joined. */ export function defaultRespondPolicy(ctx: AiTurnContext): boolean { - const active = ctx.thread.participants.filter(p => !p.leftAt); + const active = ctx.thread.participants.filter((p) => !p.leftAt); if (active.length <= 2) return afterEveryMessage(ctx); return whenMentioned([ctx.self.role ?? 'assistant', 'ai'])(ctx); } @@ -98,12 +98,9 @@ function trailingSelfMessages(visible: readonly Message[], actorId: string): num return n; } -function renderTranscript( - ctx: AiTurnContext, - label: (p: Participant) => string -): string { +function renderTranscript(ctx: AiTurnContext, label: (p: Participant) => string): string { return ctx.visible - .map(m => { + .map((m) => { const author = findParticipant(ctx.thread, m.authorId); const who = author ? label(author) : 'unknown'; return `${who}: ${m.body}`; @@ -124,7 +121,7 @@ function renderTranscript( export async function runAiTurn( thread: Thread, allMessages: readonly Message[], - config: AiParticipantConfig + config: AiParticipantConfig, ): Promise { const self = findParticipant(thread, config.actorId); if (!self) return { status: 'skipped', reason: 'not a participant in this thread' }; diff --git a/src/index.ts b/src/index.ts index 584cac4..951f77d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,15 +22,5 @@ export { unreadCount, unreadMessages, unreadThreadCount } from './unread.js'; export { compareMessages, isPending, mergeMessages } from './merge.js'; -export { - afterEveryMessage, - defaultRespondPolicy, - runAiTurn, - whenMentioned, -} from './ai.js'; -export type { - AiParticipantConfig, - AiTurnContext, - AiTurnResult, - RespondPolicy, -} from './ai.js'; +export { afterEveryMessage, defaultRespondPolicy, runAiTurn, whenMentioned } from './ai.js'; +export type { AiParticipantConfig, AiTurnContext, AiTurnResult, RespondPolicy } from './ai.js'; diff --git a/src/merge.ts b/src/merge.ts index 1d9ee1f..494ac15 100644 --- a/src/merge.ts +++ b/src/merge.ts @@ -26,7 +26,7 @@ export function compareMessages(a: Message, b: Message): number { */ export function mergeMessages( existing: readonly Message[], - incoming: readonly Message[] + incoming: readonly Message[], ): Message[] { const byId = new Map(); const idByClientId = new Map(); diff --git a/src/participants.ts b/src/participants.ts index 7f72be9..89aecb7 100644 --- a/src/participants.ts +++ b/src/participants.ts @@ -10,11 +10,8 @@ import type { Message, Participant, Thread } from './types.js'; * thread does not have that failure mode. */ -export function findParticipant( - thread: Thread, - actorId: string -): Participant | undefined { - return thread.participants.find(p => p.actorId === actorId); +export function findParticipant(thread: Thread, actorId: string): Participant | undefined { + return thread.participants.find((p) => p.actorId === actorId); } /** Has this actor ever been part of the thread? Necessary, never sufficient — see `canSeeMessage`. */ @@ -63,11 +60,11 @@ export function canSeeMessage(p: Participant, message: Message): boolean { export function visibleMessages( thread: Thread, actorId: string, - messages: readonly Message[] + messages: readonly Message[], ): Message[] { const p = findParticipant(thread, actorId); if (!p) return []; - return messages.filter(m => m.threadId === thread.id && canSeeMessage(p, m)); + return messages.filter((m) => m.threadId === thread.id && canSeeMessage(p, m)); } /** @@ -78,7 +75,7 @@ export function visibleMessages( * access that does not exist. */ export function readersOf(thread: Thread, message: Message): Participant[] { - return thread.participants.filter(p => { + return thread.participants.filter((p) => { if (p.actorId === message.authorId) return false; if (!canSeeMessage(p, message)) return false; if (!p.lastReadAt) return false; diff --git a/src/unread.ts b/src/unread.ts index 13d03ef..9673885 100644 --- a/src/unread.ts +++ b/src/unread.ts @@ -15,12 +15,12 @@ import { findParticipant, canSeeMessage } from './participants.js'; export function unreadMessages( thread: Thread, actorId: string, - messages: readonly Message[] + messages: readonly Message[], ): Message[] { const p = findParticipant(thread, actorId); if (!p) return []; const since = p.lastReadAt?.getTime() ?? null; - return messages.filter(m => { + return messages.filter((m) => { if (m.threadId !== thread.id) return false; if (m.authorId === actorId) return false; if (!canSeeMessage(p, m)) return false; @@ -30,11 +30,7 @@ export function unreadMessages( }); } -export function unreadCount( - thread: Thread, - actorId: string, - messages: readonly Message[] -): number { +export function unreadCount(thread: Thread, actorId: string, messages: readonly Message[]): number { return unreadMessages(thread, actorId, messages).length; } @@ -46,7 +42,7 @@ export function unreadCount( */ export function unreadThreadCount( entries: readonly { thread: Thread; messages: readonly Message[] }[], - actorId: string + actorId: string, ): number { - return entries.filter(e => unreadCount(e.thread, actorId, e.messages) > 0).length; + return entries.filter((e) => unreadCount(e.thread, actorId, e.messages) > 0).length; } diff --git a/test/ai.test.js b/test/ai.test.js index 95d7e7c..d0d40b8 100644 --- a/test/ai.test.js +++ b/test/ai.test.js @@ -18,7 +18,7 @@ function msg(id, authorId, createdAt, body = 'hello') { /** Records what the model was shown, so the test can assert on the prompt itself. */ function spyComplete(reply = 'a reply') { const seen = []; - const complete = async input => { + const complete = async (input) => { seen.push(input); return reply; }; @@ -47,7 +47,7 @@ test('the AI sees only what it was granted, not the whole transcript', async () assert.equal(seen.length, 1); assert.ok( !seen[0].prompt.includes('private history'), - 'a model added later must not be handed the history it was not granted' + 'a model added later must not be handed the history it was not granted', ); assert.ok(seen[0].prompt.includes('summarise my sleep')); }); diff --git a/test/authorization.test.js b/test/authorization.test.js index f85bc44..69f3d10 100644 --- a/test/authorization.test.js +++ b/test/authorization.test.js @@ -1,13 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { - canRead, - canWrite, - readersOf, - unreadCount, - visibleMessages, -} from '../dist/index.js'; +import { canRead, canWrite, readersOf, unreadCount, visibleMessages } from '../dist/index.js'; const T0 = new Date('2026-01-01T09:00:00Z'); const T1 = new Date('2026-01-01T10:00:00Z'); @@ -40,7 +34,7 @@ test('a clinician added later does not see what was said before they joined', () const t = thread([patient, drA, drB]); const messages = [msg('m1', 'patient-1', T1), msg('m2', 'dr-a', T3)]; - const seen = visibleMessages(t, 'dr-b', messages).map(m => m.id); + const seen = visibleMessages(t, 'dr-b', messages).map((m) => m.id); assert.deepEqual(seen, ['m2'], 'history before joining is not granted by default'); }); @@ -55,7 +49,7 @@ test('granting thread-start hands over the whole history, on purpose', () => { const t = thread([patient, drA, drB]); const messages = [msg('m1', 'patient-1', T1), msg('m2', 'dr-a', T3)]; - const seen = visibleMessages(t, 'dr-b', messages).map(m => m.id); + const seen = visibleMessages(t, 'dr-b', messages).map((m) => m.id); assert.deepEqual(seen, ['m1', 'm2']); }); @@ -70,7 +64,10 @@ test('someone who left keeps their history and stops receiving what comes next', const t = thread([patient, locum]); const messages = [msg('m1', 'patient-1', T1), msg('m2', 'patient-1', T3)]; - assert.deepEqual(visibleMessages(t, 'locum', messages).map(m => m.id), ['m1']); + assert.deepEqual( + visibleMessages(t, 'locum', messages).map((m) => m.id), + ['m1'], + ); assert.equal(canWrite(t, 'locum', T3), false, 'a departed participant loses their voice'); assert.equal(canRead(t, 'locum'), true, 'but not what they already saw'); }); @@ -112,5 +109,8 @@ test('a read receipt never implies access the reader does not have', () => { const early = msg('m1', 'patient-1', T1); // dr-b's clock says they have read up to T3, but m1 predates their window. - assert.deepEqual(readersOf(t, early).map(p => p.actorId), []); + assert.deepEqual( + readersOf(t, early).map((p) => p.actorId), + [], + ); }); diff --git a/test/merge.test.js b/test/merge.test.js index a07abab..fe7bc66 100644 --- a/test/merge.test.js +++ b/test/merge.test.js @@ -41,8 +41,8 @@ test('messages sharing a timestamp keep a stable order', () => { const a = { id: 'b', threadId: 't1', authorId: 'x', body: 'a', createdAt: T1 }; const b = { id: 'a', threadId: 't1', authorId: 'x', body: 'b', createdAt: T1 }; - const once = mergeMessages([a, b], []).map(m => m.id); - const twice = mergeMessages([b, a], []).map(m => m.id); + const once = mergeMessages([a, b], []).map((m) => m.id); + const twice = mergeMessages([b, a], []).map((m) => m.id); assert.deepEqual(once, twice, 'insertion order must not change what the user sees'); assert.deepEqual(once, ['a', 'b']); }); @@ -51,7 +51,10 @@ test('merging is ordered by time, not by arrival', () => { const late = { id: 'm2', threadId: 't1', authorId: 'x', body: 'second', createdAt: T2 }; const early = { id: 'm1', threadId: 't1', authorId: 'x', body: 'first', createdAt: T1 }; - assert.deepEqual(mergeMessages([late], [early]).map(m => m.id), ['m1', 'm2']); + assert.deepEqual( + mergeMessages([late], [early]).map((m) => m.id), + ['m1', 'm2'], + ); }); test('a pending message is distinguishable from a confirmed one', () => { diff --git a/test/package.test.js b/test/package.test.js index 1f3a122..d2a998b 100644 --- a/test/package.test.js +++ b/test/package.test.js @@ -52,7 +52,7 @@ before(() => { cwd: process.cwd(), stdio: ['ignore', 'ignore', 'inherit'], }); - const tarball = readdirSync(workspace).find(f => f.endsWith('.tgz')); + const tarball = readdirSync(workspace).find((f) => f.endsWith('.tgz')); assert.ok(tarball, 'npm pack produced no tarball'); execFileSync('tar', ['-xzf', join(workspace, tarball), '-C', installed, '--strip-components=1']); @@ -67,14 +67,14 @@ before(() => { 'try { out.resolved = import.meta.resolve("threadkit"); } catch { out.resolved = null; }', 'try { out.exports = Object.keys(await import("threadkit")).sort(); } catch { out.exports = null; }', 'console.log(JSON.stringify(out));', - ].join('\n') + ].join('\n'), ); probe = JSON.parse( execFileSync('node', [join(workspace, 'probe.mjs')], { cwd: workspace, encoding: 'utf8', - }) + }), ); }); @@ -94,12 +94,12 @@ test('the type declarations it advertises are actually in the tarball', () => { execFileSync('node', ['-p', 'JSON.stringify(require("./package.json"))'], { cwd: installed, encoding: 'utf8', - }) + }), ); const types = pkg.exports['.'].types; assert.ok( existsSync(join(installed, types)), - `the package advertises types at ${types}, which is not in the tarball` + `the package advertises types at ${types}, which is not in the tarball`, ); }); diff --git a/test/unread.test.js b/test/unread.test.js index fa1d78e..ebfb84e 100644 --- a/test/unread.test.js +++ b/test/unread.test.js @@ -41,8 +41,8 @@ test('a participant who has never read owes every visible message from someone e const messages = [msg('m1', 'patient-1', T1), msg('m2', 'patient-1', T2)]; assert.deepEqual( - unreadMessages(thread, 'dr-a', messages).map(m => m.id), - ['m1', 'm2'] + unreadMessages(thread, 'dr-a', messages).map((m) => m.id), + ['m1', 'm2'], ); }); @@ -55,14 +55,14 @@ test('unread never reaches back past the day you joined', () => { assert.equal( unreadCount(thread, 'dr-b', messages), 0, - 'a message from before dr-b joined is not theirs to owe' + 'a message from before dr-b joined is not theirs to owe', ); }); test('the nav badge counts threads that want you, not messages to read', () => { const me = { actorId: 'me', kind: 'human', joinedAt: T0, lastReadAt: null }; const other = { actorId: 'other', kind: 'human', joinedAt: T0 }; - const mk = id => ({ id, participants: [me, other], createdAt: T0 }); + const mk = (id) => ({ id, participants: [me, other], createdAt: T0 }); const entries = [ { @@ -75,5 +75,9 @@ test('the nav badge counts threads that want you, not messages to read', () => { { thread: mk('t2'), messages: [] }, ]; - assert.equal(unreadThreadCount(entries, 'me'), 1, 'two unread messages in one thread is one badge'); + assert.equal( + unreadThreadCount(entries, 'me'), + 1, + 'two unread messages in one thread is one badge', + ); });