From 06c5a5fb52792b7f31755a8d609d1a6c4dfd9454 Mon Sep 17 00:00:00 2001 From: Maximilian Kindshofer Date: Wed, 9 Sep 2026 21:05:57 +0200 Subject: [PATCH 1/2] fix(portal): count only open posts beside a board The number beside a board in the portal sidebar counted every post the viewer was allowed to see, while the list under it hides complete and closed posts by default and never shows a post that was merged into another. A board read "3" over two visible posts. The count now applies the same default as the list: a post counts when it has no status or an active-category status, and is not merged, deleted, or hidden from the viewer by moderation. The status predicate moves into post.portal-default-status.ts and both the list and the count read it from there, so the two cannot drift apart again. After deploying, the portal sidebar shows smaller numbers on boards with completed or closed posts. Settings > Boards and GET /api/v1/boards keep counting every post; the apps API does not expose a count. Co-Authored-By: Claude Fable 5.1 --- .../board-public-post-count.db.test.ts | 306 ++++++++++++++++++ .../lib/server/domains/boards/board.public.ts | 18 +- .../posts/post.portal-default-status.ts | 29 ++ .../lib/server/domains/posts/post.public.ts | 12 +- scripts/__tests__/mutation-scope.test.ts | 7 + scripts/mutation-manifest.json | 7 + 6 files changed, 368 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/lib/server/domains/boards/__tests__/board-public-post-count.db.test.ts create mode 100644 apps/web/src/lib/server/domains/posts/post.portal-default-status.ts diff --git a/apps/web/src/lib/server/domains/boards/__tests__/board-public-post-count.db.test.ts b/apps/web/src/lib/server/domains/boards/__tests__/board-public-post-count.db.test.ts new file mode 100644 index 0000000000..d9ba7737a1 --- /dev/null +++ b/apps/web/src/lib/server/domains/boards/__tests__/board-public-post-count.db.test.ts @@ -0,0 +1,306 @@ +/** + * The number the portal shows beside a board, measured against a real + * database. + * + * Contract, confirmed before the implementation: + * + * V1 For the same viewer, the number shown beside a board equals the number of + * posts the board's default portal list holds. The two never disagree. + * V2 A post whose status is in the "complete" or "closed" category is not + * counted. + * V3 A post with an "active" status, or with no status at all, is counted. + * V4 A post that was merged into another post is not counted. The post it was + * merged into counts once. + * V5 A board with nothing to count is still listed, with zero. + * V6 Everything the count already hid stays hidden: deleted posts, posts the + * viewer may not see because of moderation, and boards the viewer may not + * see. A team member's count includes what a team member sees. + * V7 The count reads the status the way the list does: a status that was + * deleted but is still attached to a post does not change whether the post + * counts. Only its category does. + * + * Every board is found by its own id in the result, never by position and + * never through the result's length: the suites share one database and run in + * parallel, so other boards come and go while this file runs. + */ +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import fc from 'fast-check' +import { + createId, + type BoardId, + type PostId, + type PostStatusId, + type PrincipalId, + type UserId, +} from '@quackback/ids' +import { createDbTestFixture, testDb } from '@/lib/server/__tests__/db-test-fixture' +import { boards, posts, postStatuses, principal, user } from '@/lib/server/db' +import { DEFAULT_BOARD_ACCESS } from '@/lib/shared/db-types' +import { ANONYMOUS_ACTOR, type Actor } from '@/lib/server/policy' + +vi.mock('@/lib/server/db', async (importOriginal) => ({ + ...(await importOriginal()), + db: (await import('@/lib/server/__tests__/db-test-fixture')).testDb, +})) + +import { listPublicBoardsWithStats } from '../board.public' +import { listPublicPostsWithVotesAndAvatars } from '../../posts/post.public' + +const fixture = await createDbTestFixture({ + probe: async (db) => { + await db + .select({ statusId: posts.statusId, canonicalPostId: posts.canonicalPostId }) + .from(posts) + .limit(0) + await db.select({ category: postStatuses.category }).from(postStatuses).limit(0) + }, +}) + +type Category = 'active' | 'complete' | 'closed' + +const suffix = () => `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` + +async function seedPrincipal(role: 'admin' | 'user'): Promise { + const userId = createId('user') as UserId + const principalId = createId('principal') as PrincipalId + await testDb.insert(user).values({ id: userId, name: `${role} ${suffix()}` }) + await testDb.insert(principal).values({ + id: principalId, + userId, + role, + type: 'user', + displayName: role, + createdAt: new Date(), + }) + return principalId +} + +async function seedBoard( + view: 'anonymous' | 'team' = 'anonymous' +): Promise<{ id: BoardId; slug: string }> { + const slug = `count-${suffix()}` + const [board] = await testDb + .insert(boards) + .values({ slug, name: 'Counted board', access: { ...DEFAULT_BOARD_ACCESS, view } }) + .returning() + return { id: board.id, slug } +} + +async function seedStatus(category: Category, deleted = false): Promise { + const [status] = await testDb + .insert(postStatuses) + .values({ + name: `${category} ${suffix()}`, + slug: `count-${category}-${suffix()}`, + category, + deletedAt: deleted ? new Date() : null, + }) + .returning() + return status.id +} + +interface PostSeed { + boardId: BoardId + authorId: PrincipalId + statusId: PostStatusId | null + mergedInto?: PostId + deleted?: boolean + pending?: boolean +} + +async function seedPost(seed: PostSeed): Promise { + const [post] = await testDb + .insert(posts) + .values({ + boardId: seed.boardId, + principalId: seed.authorId, + title: 'Counted post', + content: '', + statusId: seed.statusId, + canonicalPostId: seed.mergedInto ?? null, + deletedAt: seed.deleted ? new Date() : null, + moderationState: seed.pending ? 'pending' : 'published', + }) + .returning() + return post.id +} + +function teamMember(principalId: PrincipalId): Actor { + return { principalId, role: 'admin', principalType: 'user', segmentIds: new Set() } +} + +/** The number the sidebar shows beside this board, or undefined when the board is not listed at all. */ +async function countShown(boardId: BoardId, viewer: Actor): Promise { + const listed = await listPublicBoardsWithStats(viewer) + return listed.find((board) => board.id === boardId)?.postCount +} + +/** How many posts the board's default portal list holds for this viewer. */ +async function postsListed(boardSlug: string, viewer: Actor): Promise { + const result = await listPublicPostsWithVotesAndAvatars({ actor: viewer, boardSlug, limit: 100 }) + return result.items.length +} + +describe.skipIf(!fixture.available)('portal board count (real DB)', () => { + beforeEach(fixture.begin) + afterEach(fixture.rollback) + afterAll(fixture.close) + + it('counts active and unstatused posts and leaves complete and closed ones out (V2, V3, V1)', async () => { + const author = await seedPrincipal('user') + const board = await seedBoard() + const active = await seedStatus('active') + const complete = await seedStatus('complete') + const closed = await seedStatus('closed') + await seedPost({ boardId: board.id, authorId: author, statusId: active }) + await seedPost({ boardId: board.id, authorId: author, statusId: null }) + await seedPost({ boardId: board.id, authorId: author, statusId: complete }) + await seedPost({ boardId: board.id, authorId: author, statusId: closed }) + + expect(await countShown(board.id, ANONYMOUS_ACTOR)).toBe(2) + expect(await postsListed(board.slug, ANONYMOUS_ACTOR)).toBe(2) + }) + + it('does not count a post merged into another, and counts the target once (V4, V1)', async () => { + const author = await seedPrincipal('user') + const board = await seedBoard() + const active = await seedStatus('active') + const target = await seedPost({ boardId: board.id, authorId: author, statusId: active }) + await seedPost({ boardId: board.id, authorId: author, statusId: active, mergedInto: target }) + await seedPost({ boardId: board.id, authorId: author, statusId: null, mergedInto: target }) + + expect(await countShown(board.id, ANONYMOUS_ACTOR)).toBe(1) + expect(await postsListed(board.slug, ANONYMOUS_ACTOR)).toBe(1) + }) + + it('lists a board with no posts, and one whose posts are all closed, with zero (V5)', async () => { + const author = await seedPrincipal('user') + const empty = await seedBoard() + const allClosed = await seedBoard() + const closed = await seedStatus('closed') + await seedPost({ boardId: allClosed.id, authorId: author, statusId: closed }) + await seedPost({ boardId: allClosed.id, authorId: author, statusId: closed }) + + expect(await countShown(empty.id, ANONYMOUS_ACTOR)).toBe(0) + expect(await countShown(allClosed.id, ANONYMOUS_ACTOR)).toBe(0) + }) + + it('keeps deleted and pending posts out for a visitor, and pending ones in for a team member (V6, V1)', async () => { + const author = await seedPrincipal('user') + const admin = await seedPrincipal('admin') + const board = await seedBoard() + const active = await seedStatus('active') + await seedPost({ boardId: board.id, authorId: author, statusId: active }) + await seedPost({ boardId: board.id, authorId: author, statusId: active, deleted: true }) + await seedPost({ boardId: board.id, authorId: author, statusId: active, pending: true }) + + expect(await countShown(board.id, ANONYMOUS_ACTOR)).toBe(1) + expect(await postsListed(board.slug, ANONYMOUS_ACTOR)).toBe(1) + expect(await countShown(board.id, teamMember(admin))).toBe(2) + expect(await postsListed(board.slug, teamMember(admin))).toBe(2) + }) + + it('does not list a team-only board to a visitor at all (V6)', async () => { + const author = await seedPrincipal('user') + const admin = await seedPrincipal('admin') + const board = await seedBoard('team') + await seedPost({ boardId: board.id, authorId: author, statusId: null }) + + expect(await countShown(board.id, ANONYMOUS_ACTOR)).toBeUndefined() + expect(await countShown(board.id, teamMember(admin))).toBe(1) + }) + + it('decides by the status category even when the status itself was deleted (V7, V1)', async () => { + const author = await seedPrincipal('user') + const board = await seedBoard() + const deletedActive = await seedStatus('active', true) + const deletedComplete = await seedStatus('complete', true) + await seedPost({ boardId: board.id, authorId: author, statusId: deletedActive }) + await seedPost({ boardId: board.id, authorId: author, statusId: deletedComplete }) + + expect(await countShown(board.id, ANONYMOUS_ACTOR)).toBe(1) + expect(await postsListed(board.slug, ANONYMOUS_ACTOR)).toBe(1) + }) + + interface GeneratedPost { + category: 'none' | Category + statusDeleted: boolean + merged: boolean + deleted: boolean + pending: boolean + } + + /** What the contract says should be counted, computed from the seed alone. */ + function isOpen(post: GeneratedPost): boolean { + if (post.deleted || post.merged) return false + return post.category === 'none' || post.category === 'active' + } + + /** + * Contract: V1, with V2–V4, V6 and V7 as the states the generator reaches. + * + * Each run seeds a fresh board with one anchor post (active, published, + * unmerged: it is what the merged posts are merged into) and up to ten + * generated posts, each with a status category (none, active, complete, + * closed), a live or a deleted status row, merged or not, deleted or not, + * pending moderation or not. Two viewers look at the board: an anonymous + * visitor and a team member. + * + * Two assertions per viewer, and both are needed. The count is compared + * against the default portal list, which is V1 as stated. It is also + * compared against a number computed here from the seed alone, because the + * list and the count now share their status predicate, and two users of one + * helper agree with each other even when both are wrong. + */ + it('shows beside a board exactly the number of posts its default list holds, for any mix of posts (V1)', async () => { + const author = await seedPrincipal('user') + const admin = await seedPrincipal('admin') + const statusIds: Record = { + active: { live: await seedStatus('active'), deleted: await seedStatus('active', true) }, + complete: { live: await seedStatus('complete'), deleted: await seedStatus('complete', true) }, + closed: { live: await seedStatus('closed'), deleted: await seedStatus('closed', true) }, + } + + const generatedPost: fc.Arbitrary = fc.record({ + category: fc.constantFrom<'none' | Category>('none', 'active', 'complete', 'closed'), + statusDeleted: fc.boolean(), + merged: fc.boolean(), + deleted: fc.boolean(), + pending: fc.boolean(), + }) + + await fc.assert( + fc.asyncProperty(fc.array(generatedPost, { maxLength: 10 }), async (generated) => { + const board = await seedBoard() + const anchor = await seedPost({ + boardId: board.id, + authorId: author, + statusId: statusIds.active.live, + }) + for (const spec of generated) { + let statusId: PostStatusId | null = null + if (spec.category !== 'none') { + statusId = statusIds[spec.category][spec.statusDeleted ? 'deleted' : 'live'] + } + await seedPost({ + boardId: board.id, + authorId: author, + statusId, + mergedInto: spec.merged ? anchor : undefined, + deleted: spec.deleted, + pending: spec.pending, + }) + } + + const openPosts = generated.filter(isOpen) + const expectedForVisitor = 1 + openPosts.filter((post) => !post.pending).length + const expectedForTeam = 1 + openPosts.length + + expect(await countShown(board.id, ANONYMOUS_ACTOR)).toBe(expectedForVisitor) + expect(await postsListed(board.slug, ANONYMOUS_ACTOR)).toBe(expectedForVisitor) + expect(await countShown(board.id, teamMember(admin))).toBe(expectedForTeam) + expect(await postsListed(board.slug, teamMember(admin))).toBe(expectedForTeam) + }) + ) + }, 60_000) +}) diff --git a/apps/web/src/lib/server/domains/boards/board.public.ts b/apps/web/src/lib/server/domains/boards/board.public.ts index c64aae4d6c..e9a6aa6319 100644 --- a/apps/web/src/lib/server/domains/boards/board.public.ts +++ b/apps/web/src/lib/server/domains/boards/board.public.ts @@ -3,6 +3,7 @@ import { getTableColumns } from 'drizzle-orm' import type { BoardId } from '@quackback/ids' import { InternalError } from '@/lib/shared/errors' import type { BoardWithStats } from './board.types' +import { portalDefaultStatusFilter } from '../posts/post.portal-default-status' import { boardViewFilter, postViewFilter, ANONYMOUS_ACTOR, type Actor } from '@/lib/server/policy' /** @@ -58,9 +59,12 @@ export async function listPublicBoardsWithStats( actor: Actor = ANONYMOUS_ACTOR ): Promise { try { - // The post-count join must apply postViewFilter, not just isNull(deletedAt) — - // otherwise the count leaks pending/spam/archived posts to non-team users - // and disagrees with what the actual post list shows them. + // The post-count join must count exactly what the default portal list + // shows this actor, or the number beside a board disagrees with the list + // under it. That means postViewFilter (not just isNull(deletedAt), which + // would leak pending/spam posts to non-team users), no merged posts, and + // the portal's default status filter: complete and closed posts are not + // shown, so they are not counted. const rows = await db .select({ ...getTableColumns(boards), @@ -69,7 +73,13 @@ export async function listPublicBoardsWithStats( .from(boards) .leftJoin( posts, - and(eq(posts.boardId, boards.id), isNull(posts.deletedAt), postViewFilter(actor)) + and( + eq(posts.boardId, boards.id), + isNull(posts.deletedAt), + isNull(posts.canonicalPostId), + portalDefaultStatusFilter(), + postViewFilter(actor) + ) ) // boardViewFilter embeds isNull(boards.deletedAt) in every branch — no // outer guard needed here. Callers of postViewFilter still need their diff --git a/apps/web/src/lib/server/domains/posts/post.portal-default-status.ts b/apps/web/src/lib/server/domains/posts/post.portal-default-status.ts new file mode 100644 index 0000000000..e6a97fd3c0 --- /dev/null +++ b/apps/web/src/lib/server/domains/posts/post.portal-default-status.ts @@ -0,0 +1,29 @@ +import type { SQL } from 'drizzle-orm' +import { db, eq, inArray, isNull, or, posts, postStatuses } from '@/lib/server/db' + +/** + * The portal's default notion of an open post: it has no status, or its + * status is in the `active` category. Complete and closed posts fall out of + * the default portal list, and therefore also out of the number the portal + * shows beside a board. + * + * Both the list (`post.public.ts`) and the board count (`board.public.ts`) + * take their predicate from here so the two cannot drift apart again. They + * did once: the count included closed posts the list never showed, so a board + * read "3" over two visible posts. + * + * Only the status's category is consulted. A status that was soft-deleted but + * is still attached to a post keeps deciding by its category, exactly as the + * list has always read it. + * + * Built on every call rather than once at module scope: a query builder + * evaluated at import time turns every mutant inside it into a + * suite-collection crash, which Stryker reports as survived (SELF-IMPROVE.md). + */ +export function portalDefaultStatusFilter(): SQL { + const activeStatusIds = db + .select({ id: postStatuses.id }) + .from(postStatuses) + .where(eq(postStatuses.category, 'active')) + return or(isNull(posts.statusId), inArray(posts.statusId, activeStatusIds))! +} diff --git a/apps/web/src/lib/server/domains/posts/post.public.ts b/apps/web/src/lib/server/domains/posts/post.public.ts index 9969f5c0bd..e8377ce593 100644 --- a/apps/web/src/lib/server/domains/posts/post.public.ts +++ b/apps/web/src/lib/server/domains/posts/post.public.ts @@ -2,7 +2,6 @@ import { db, eq, and, - or, inArray, desc, sql, @@ -26,6 +25,7 @@ import { type PrincipalId, type SegmentId, } from '@quackback/ids' +import { portalDefaultStatusFilter } from './post.portal-default-status' import type { PublicPostListResult } from './post.types' import type { RespondedFilter } from '@/lib/shared/types/filters' import { postViewFilter, isTeamActor, ANONYMOUS_ACTOR, type Actor } from '@/lib/server/policy' @@ -166,12 +166,10 @@ function buildPostFilterConditions(params: PostListParams, actor: Actor) { } else if (statusIds && statusIds.length > 0) { conditions.push(inArray(posts.statusId, statusIds)) } else { - // Default: exclude complete/closed posts — only show active-category statuses (or unstatused) - const activeStatusSubquery = db - .select({ id: postStatuses.id }) - .from(postStatuses) - .where(eq(postStatuses.category, 'active')) - conditions.push(or(isNull(posts.statusId), inArray(posts.statusId, activeStatusSubquery))!) + // Default: hide complete/closed posts. The predicate is shared with the + // board count in board.public.ts, so the number beside a board and the + // list under it agree. + conditions.push(portalDefaultStatusFilter()) } if (tagIds && tagIds.length > 0) { diff --git a/scripts/__tests__/mutation-scope.test.ts b/scripts/__tests__/mutation-scope.test.ts index 448252e1a7..527b9cb97c 100644 --- a/scripts/__tests__/mutation-scope.test.ts +++ b/scripts/__tests__/mutation-scope.test.ts @@ -174,6 +174,13 @@ describe('the files the mutation gate is declared to grade (B4)', () => { 'apps/web/src/components/admin/settings/integrations/__tests__/oauth-connection-actions.test.tsx', ], }, + { + file: 'apps/web/src/lib/server/domains/posts/post.portal-default-status.ts', + suites: [ + 'apps/web/src/lib/server/domains/boards/__tests__/board-public-post-count.db.test.ts', + 'apps/web/src/lib/server/domains/posts/__tests__/post-public.test.ts', + ], + }, ]) }) diff --git a/scripts/mutation-manifest.json b/scripts/mutation-manifest.json index 12608c89e1..e431012b1b 100644 --- a/scripts/mutation-manifest.json +++ b/scripts/mutation-manifest.json @@ -146,6 +146,13 @@ "suites": [ "apps/web/src/components/admin/settings/integrations/__tests__/oauth-connection-actions.test.tsx" ] + }, + { + "file": "apps/web/src/lib/server/domains/posts/post.portal-default-status.ts", + "suites": [ + "apps/web/src/lib/server/domains/boards/__tests__/board-public-post-count.db.test.ts", + "apps/web/src/lib/server/domains/posts/__tests__/post-public.test.ts" + ] } ], "equivalents": [ From b8e17ed7d4948dc478a0096527e51882244d4b84 Mon Sep 17 00:00:00 2001 From: Maximilian Kindshofer Date: Wed, 9 Sep 2026 21:08:35 +0200 Subject: [PATCH 2/2] docs(self-improve): fifth hit of the all-or-nothing manifest, on the board count Nothing in the running service changes. Co-Authored-By: Claude Fable 5.1 --- SELF-IMPROVE.md | 184 ++++++++++++++++++++++++++---------------------- 1 file changed, 98 insertions(+), 86 deletions(-) diff --git a/SELF-IMPROVE.md b/SELF-IMPROVE.md index f2ed9d97b4..d44782703c 100644 --- a/SELF-IMPROVE.md +++ b/SELF-IMPROVE.md @@ -103,6 +103,104 @@ terminal row in one pass") saw `pruned` come back 0 for a row it had just aged so whichever process prunes first takes the other's row and its count. Same shape as `seat-usage`: a database-wide count asserted across parallel suites. +## 5x — The mutation manifest is all-or-nothing per file, so one upstream line can lock a file out + +An entry declares a whole file, and the gate fails on any survivor in it. A change that +adds three lines to an upstream file therefore has to pin **every** branch that file +already had, including ones its own diff never touched. + +Measured on `post.board.ts`: declaring it produced nine survivors. Six were real and are +now killed — one of them, `db.query.posts.findFirst({ where: ... })` losing its `where`, +is the same class of bug as an integration lookup returning _an_ integration instead of +_the_ one. Two more sit on branches a foreign key and an open transaction make +unreachable, so no input reaches them. The last is `db.query.boards.findFirst({ where: ... +})` for the board the post came _from_: dropping the `where` returns a different board and +really does change the payload, but which row an unordered query hands back is not +something a test may rely on, and the board is fetched for its `name`, so the lookup +cannot be removed either. It is upstream code the change did not touch. + +That one mutant blocks the entry for the whole file, because declaring it would assert +"these suites hold this file" — and they do not. So the file goes back to being reported +by name as ungraded, and six verified kills sit in the suite without the gate knowing. + +**A per-file `except` list, addressed by line text the way `equivalents` already is, would +let a change declare the part it owns** and leave the untouched remainder named in the +report. Without it the incentive runs the wrong way: the cheapest way to keep a gate green +is to not declare the file, which is the outcome the manifest exists to prevent. + +The tests stay either way — writing them turned up two existing tests that never entered +the branch they named (see the entry below on hand-typed TypeIDs). + +Second occurrence, on the work item URL fix. Declaring `url.ts` — one changed +line, a regex — meant asserting that its suite pins the **whole** file, including +`normalizeGitLabInstanceUrl`, which the change never touched. Nine mutants +survived the first run and eight of them were in that pre-existing half. Eight +were worth killing anyway, but the ninth forced a contract decision (`http://` +as an instance address) that had nothing to do with the change and could not be +deferred, because the gate is per file and there is no way to say "grade the +line I touched". + +The shape that would help is unchanged: a per-file `except` list, or scoping an +entry to a diff range. Until then, declaring a file with pre-existing untested +neighbours is a decision to be made deliberately, not a formality. + +Third occurrence, on the GitLab token renewal — and this one found a way around +it worth repeating. Declaring the two files the change touched produced **48** +survivors, 41 of them in halves the change never opened: `oauth.ts` carries an +authorization-URL builder and a code exchange, and `token-refresh.ts` carries a +`db.query.integrations.findFirst({ where })` whose unfiltered mutant is the same +undeterminable case as `post.board.ts` above. + +So the new function moved into its own module, `gitlab/server/token-renewal.ts`, +which the new suite pins on its own: 22 mutants, 22 killed, and the two +pre-existing files reported by name as ungraded. **Putting new logic in a new +file is currently the only way to have it mutation-graded without adopting its +neighbours**, and it is worth doing deliberately for that reason alone — not +only when the module boundary is independently justified. Jira's +`server/token.ts` is the same shape, probably for the same reason. + +The seven survivors that remained were all real: nothing asserted the request +was a POST, nothing passed `credentials: undefined` — which is what the +framework actually passes when no platform credentials are stored +(`credentials ?? undefined`), so the optional chaining that mutant removed is +load-bearing rather than defensive. + +Fourth occurrence, and the largest so far, on the notification names of the +i18n work. The change added four exports to `notifications/catalog.ts` — three +id builders and a group-label map — and declaring that file produced **57 +survivors plus 7 mutants nothing executed**, against 0 for everything else in +the run. None of the 57 were in the four new exports. They were upstream's +25-row data table: every `surfaces: ['admin', 'portal']` as `[]` and as `''`, +because nothing in the repository asserts which settings surface renders which +notification row, and `catalogByGroup`, which the suite beside the module never +calls. + +The workaround recorded above worked again, and it is now the third time: +the four exports moved into `notifications/message-ids.ts`, its suite pins it +whole, and `catalog.ts` went back to **byte-identical to upstream** — which is +worth as much as the grading, because it is a file that no longer appears in a +sync. 459 mutants, 453 killed, 6 excused, 0 ungraded. + +The thing to take from the fourth occurrence is that the decision is cheap to +get right and expensive to get wrong in only one direction. Declaring a file +costs a gate run (~8 minutes here) to find out whether the claim was true, and +the answer arrives as a survivor count that says nothing about which half it +came from until you read every line number. Reading the file first and asking +"does the suite I wrote assert the parts of this I am not touching" takes a +minute. For an upstream file with a data table in it, the answer is no. + +Fifth occurrence, on the portal board count. The change added two lines to the +join in `listPublicBoardsWithStats` (`board.public.ts`), and declaring that file +would have asserted that the suites pin `getPublicBoardBySlug`, `countBoards` and +four error-message strings the change never touched. Same way out as the three +before it: the shared status predicate went into its own module, +`post.portal-default-status.ts`, declared and graded on its own, and the join +lines stayed ungraded by the gate. What stood in for the missing `except` list +was the mutate-run-restore loop from the Stryker entry, five hand-written +mutants against the new DB suite, five killed, twelve seconds each. That loop is +now the standing substitute for scoping an entry to a diff range, and it is a +minute of throwaway scripting per change that the gate could do by itself. + ## 1x — A line that is only an arrow function passed as a JSX prop reads as uncovered until the handler actually fires Filling a diff-coverage hole for `onEdit={() => onEdit(row)}`-shaped lines @@ -489,92 +587,6 @@ no-op would quietly restore the number. CI cannot catch that rot on its own — the `check` job builds before it typechecks, and the build writes the same file — which is what `apps/web/scripts/__tests__/generate-route-tree.test.ts` is for. -## 4x — The mutation manifest is all-or-nothing per file, so one upstream line can lock a file out - -An entry declares a whole file, and the gate fails on any survivor in it. A change that -adds three lines to an upstream file therefore has to pin **every** branch that file -already had, including ones its own diff never touched. - -Measured on `post.board.ts`: declaring it produced nine survivors. Six were real and are -now killed — one of them, `db.query.posts.findFirst({ where: ... })` losing its `where`, -is the same class of bug as an integration lookup returning _an_ integration instead of -_the_ one. Two more sit on branches a foreign key and an open transaction make -unreachable, so no input reaches them. The last is `db.query.boards.findFirst({ where: ... -})` for the board the post came _from_: dropping the `where` returns a different board and -really does change the payload, but which row an unordered query hands back is not -something a test may rely on, and the board is fetched for its `name`, so the lookup -cannot be removed either. It is upstream code the change did not touch. - -That one mutant blocks the entry for the whole file, because declaring it would assert -"these suites hold this file" — and they do not. So the file goes back to being reported -by name as ungraded, and six verified kills sit in the suite without the gate knowing. - -**A per-file `except` list, addressed by line text the way `equivalents` already is, would -let a change declare the part it owns** and leave the untouched remainder named in the -report. Without it the incentive runs the wrong way: the cheapest way to keep a gate green -is to not declare the file, which is the outcome the manifest exists to prevent. - -The tests stay either way — writing them turned up two existing tests that never entered -the branch they named (see the entry below on hand-typed TypeIDs). - -Second occurrence, on the work item URL fix. Declaring `url.ts` — one changed -line, a regex — meant asserting that its suite pins the **whole** file, including -`normalizeGitLabInstanceUrl`, which the change never touched. Nine mutants -survived the first run and eight of them were in that pre-existing half. Eight -were worth killing anyway, but the ninth forced a contract decision (`http://` -as an instance address) that had nothing to do with the change and could not be -deferred, because the gate is per file and there is no way to say "grade the -line I touched". - -The shape that would help is unchanged: a per-file `except` list, or scoping an -entry to a diff range. Until then, declaring a file with pre-existing untested -neighbours is a decision to be made deliberately, not a formality. - -Third occurrence, on the GitLab token renewal — and this one found a way around -it worth repeating. Declaring the two files the change touched produced **48** -survivors, 41 of them in halves the change never opened: `oauth.ts` carries an -authorization-URL builder and a code exchange, and `token-refresh.ts` carries a -`db.query.integrations.findFirst({ where })` whose unfiltered mutant is the same -undeterminable case as `post.board.ts` above. - -So the new function moved into its own module, `gitlab/server/token-renewal.ts`, -which the new suite pins on its own: 22 mutants, 22 killed, and the two -pre-existing files reported by name as ungraded. **Putting new logic in a new -file is currently the only way to have it mutation-graded without adopting its -neighbours**, and it is worth doing deliberately for that reason alone — not -only when the module boundary is independently justified. Jira's -`server/token.ts` is the same shape, probably for the same reason. - -The seven survivors that remained were all real: nothing asserted the request -was a POST, nothing passed `credentials: undefined` — which is what the -framework actually passes when no platform credentials are stored -(`credentials ?? undefined`), so the optional chaining that mutant removed is -load-bearing rather than defensive. - -Fourth occurrence, and the largest so far, on the notification names of the -i18n work. The change added four exports to `notifications/catalog.ts` — three -id builders and a group-label map — and declaring that file produced **57 -survivors plus 7 mutants nothing executed**, against 0 for everything else in -the run. None of the 57 were in the four new exports. They were upstream's -25-row data table: every `surfaces: ['admin', 'portal']` as `[]` and as `''`, -because nothing in the repository asserts which settings surface renders which -notification row, and `catalogByGroup`, which the suite beside the module never -calls. - -The workaround recorded above worked again, and it is now the third time: -the four exports moved into `notifications/message-ids.ts`, its suite pins it -whole, and `catalog.ts` went back to **byte-identical to upstream** — which is -worth as much as the grading, because it is a file that no longer appears in a -sync. 459 mutants, 453 killed, 6 excused, 0 ungraded. - -The thing to take from the fourth occurrence is that the decision is cheap to -get right and expensive to get wrong in only one direction. Declaring a file -costs a gate run (~8 minutes here) to find out whether the claim was true, and -the answer arrives as a survivor count that says nothing about which half it -came from until you read every line number. Reading the file first and asking -"does the suite I wrote assert the parts of this I am not touching" takes a -minute. For an upstream file with a data table in it, the answer is no. - ## 3x — vitest 4: dropped flags, swallowed logs, and per-file import resolution Three wasted turns diagnosing an env-leakage question, all of them spent on the