From 0e2f17c41dfa4fea50e63867ad553d3911c6dea6 Mon Sep 17 00:00:00 2001 From: Geoff Whatley Date: Tue, 21 Jul 2026 14:06:37 +1000 Subject: [PATCH] feat(format-codemod): give expect statements their own padding kind A statement whose first call in reading order is expect or an expect.* helper now classifies as its own statement kind: runs of assertions stay glued while the boundary with any acting statement takes a blank line. Previously expect chains counted as bare calls and sat flush against neighbouring bare-call actions. --- .../src/jest-extended.test.ts | 3 ++ packages/format-codemod/README.md | 5 ++- packages/format-codemod/src/transform.test.ts | 19 +++++++++ .../src/transform/needs-blank-line.test.ts | 42 ++++++++++++++++++- .../src/transform/needs-blank-line.ts | 26 ++++++++++-- 5 files changed, 89 insertions(+), 6 deletions(-) diff --git a/packages/bun-test-extended/src/jest-extended.test.ts b/packages/bun-test-extended/src/jest-extended.test.ts index 64411eb..65718f3 100644 --- a/packages/bun-test-extended/src/jest-extended.test.ts +++ b/packages/bun-test-extended/src/jest-extended.test.ts @@ -194,6 +194,7 @@ test('it counts a single call with toHaveBeenCalledOnce', () => { const fn = mock(() => null); fn(); + expect(fn).toHaveBeenCalledOnce(); }); @@ -201,6 +202,7 @@ test('it checks the single call and its args with toHaveBeenCalledExactlyOnceWit const fn = mock((value: string) => value); fn('a'); + expect(fn).toHaveBeenCalledExactlyOnceWith('a'); }); @@ -210,6 +212,7 @@ test('it orders mock calls with toHaveBeenCalledBefore and toHaveBeenCalledAfter first(); second(); + expect(first).toHaveBeenCalledBefore(second); expect(second).toHaveBeenCalledAfter(first); }); diff --git a/packages/format-codemod/README.md b/packages/format-codemod/README.md index 7b9473a..d7d4e6a 100644 --- a/packages/format-codemod/README.md +++ b/packages/format-codemod/README.md @@ -38,8 +38,11 @@ between two kinds takes a blank line: - `const`/`let`/`var` declarations - `using`/`await using` declarations - bare calls and method calls — two distinct kinds, split by the first call in reading order: - `expect(x).toBe(y)` opens with a bare function, `fs.writeFileSync(…)` opens with a member, and a + `use(x).report()` opens with a bare function, `fs.writeFileSync(…)` opens with a member, and a member call chained onto a bare call's result is still a bare call +- `expect` assertions — a statement whose first call in reading order is `expect` or an `expect.*` + helper (`expect(x).toBe(y)`, `expect.assertions(1)`) is its own kind, so a run of assertions stays + glued while the boundary with any acting statement is padded - assignments and increments Two axes overlay the kinds and pad their own boundaries: diff --git a/packages/format-codemod/src/transform.test.ts b/packages/format-codemod/src/transform.test.ts index ac22a1b..1c27bb4 100644 --- a/packages/format-codemod/src/transform.test.ts +++ b/packages/format-codemod/src/transform.test.ts @@ -591,3 +591,22 @@ test('it is a no-op when run again on code with removed blank lines', () => { expect(once).not.toBe(src); expect(twice).toBe(once); }); + +test('it pads expect runs apart from acting statements and keeps them glued', () => { + const src = `function f() {\n advance();\n expect(store.getState().generation).toBe(1);\n expect(store.getState().initialized).toBeFalse();\n advance();\n expect(store.getState().generation).toBe(2);\n}\n`; + const output = transform(src).output; + + expect(output).toMatchInlineSnapshot(` + "function f() { + advance(); + + expect(store.getState().generation).toBe(1); + expect(store.getState().initialized).toBeFalse(); + + advance(); + + expect(store.getState().generation).toBe(2); + } + " + `); +}); diff --git a/packages/format-codemod/src/transform/needs-blank-line.test.ts b/packages/format-codemod/src/transform/needs-blank-line.test.ts index b09751b..6df4862 100644 --- a/packages/format-codemod/src/transform/needs-blank-line.test.ts +++ b/packages/format-codemod/src/transform/needs-blank-line.test.ts @@ -124,7 +124,7 @@ test('it does not pad between two statements of the same kind', () => { }); test('it pads at the boundary between a method call and a bare call', () => { - const pair = parsePair("fs.writeFileSync(file, src);\nexpect(check(file)).toBe('changed');"); + const pair = parsePair('fs.writeFileSync(file, src);\ncheck(file);'); expect(needsBlankLine(pair.container, pair.prev, pair.next)).toBeTrue(); }); @@ -136,7 +136,7 @@ test('it does not pad between two method calls', () => { }); test('it treats a member call chained onto a bare call result as a bare call', () => { - const pair = parsePair('expect(a).toBe(1);\nuse(b);'); + const pair = parsePair('parse(a).check();\nuse(b);'); expect(needsBlankLine(pair.container, pair.prev, pair.next)).toBeFalse(); }); @@ -232,3 +232,41 @@ test('it does not pad between kindless statements', () => { expect(needsBlankLine(pair.container, pair.prev, pair.next)).toBeFalse(); }); + +test('it pads at the boundary between a bare call and an expect statement', () => { + const before = parsePair('advance();\nexpect(x).toBe(1);'); + const after = parsePair('expect(x).toBe(1);\nadvance();'); + + expect(needsBlankLine(before.container, before.prev, before.next)).toBeTrue(); + expect(needsBlankLine(after.container, after.prev, after.next)).toBeTrue(); +}); + +test('it does not pad between two expect statements', () => { + const pair = parsePair('expect(x).toBe(1);\nexpect(x).toBeFalse();'); + + expect(needsBlankLine(pair.container, pair.prev, pair.next)).toBeFalse(); +}); + +test('it treats an expect.* helper call as an expect statement', () => { + const pair = parsePair('expect.assertions(1);\nexpect(x).toBe(1);'); + + expect(needsBlankLine(pair.container, pair.prev, pair.next)).toBeFalse(); +}); + +test('it pads at the boundary between a method call and an expect.* helper call', () => { + const pair = parsePair('store.reset();\nexpect.assertions(1);'); + + expect(needsBlankLine(pair.container, pair.prev, pair.next)).toBeTrue(); +}); + +test('it treats a wrapping await as not changing the expect kind', () => { + const pair = parsePair('await expect(p).toReject();\nawait expect(q).toReject();'); + + expect(needsBlankLine(pair.container, pair.prev, pair.next)).toBeFalse(); +}); + +test('it treats expect in argument position as not making an expect statement', () => { + const pair = parsePair('use(expect(x));\nrun(y);'); + + expect(needsBlankLine(pair.container, pair.prev, pair.next)).toBeFalse(); +}); diff --git a/packages/format-codemod/src/transform/needs-blank-line.ts b/packages/format-codemod/src/transform/needs-blank-line.ts index ad097d5..5e61a0c 100644 --- a/packages/format-codemod/src/transform/needs-blank-line.ts +++ b/packages/format-codemod/src/transform/needs-blank-line.ts @@ -25,7 +25,8 @@ const CONTROL_FLOW_TYPES = new Set([ * declaration, on both sides of a control-flow block — its closing brace * ends a visual unit just like its opening keyword starts one — * and at the boundary between statement kinds: bare call vs method call vs - * mutation, instantiation vs anything else, and awaited vs non-awaited. Any + * expect assertion vs mutation, instantiation vs anything else, and awaited + * vs non-awaited. Any * match means exactly one blank line; a pair matching no rule sits flush. */ export function needsBlankLine(container: ASTNode, prev: ASTNode, next: ASTNode): boolean { @@ -183,15 +184,34 @@ function getStatementKind(node: ASTNode): string | null { } if (isExpressionStatementOf(node, CALL_TYPES)) { - return pickCallKind(node); + return isExpectHeaded(node) ? 'expect' : pickCallKind(node); } return isExpressionStatementOf(node, MUTATION_TYPES) ? 'mutation' : null; } +/** + * An assertion statement: the first call a reader meets is `expect` itself or + * an `expect.*` helper (`expect.soft(x)`, `expect.assertions(1)`). Asserting + * is observation rather than action, so a run of assertions stays tight — one + * checklist — while the boundary with any acting statement is padded. + */ +function isExpectHeaded(node: ASTNode): boolean { + const expression = node['expression']; + const callee = isASTNode(expression) ? findDeepestCallee(expression) : null; + + if (callee === null) { + return false; + } + + const head = collectHeadChain(callee).at(-1); + + return head?.type === 'Identifier' && head['name'] === 'expect'; +} + /** * Call statements split into two kinds by the first call a reader meets: - * `expect(x).toBe(y)` opens with a bare function, `fs.writeFileSync(...)` + * `use(x).report()` opens with a bare function, `fs.writeFileSync(...)` * opens with a member. The deepest call in the head chain decides, so a * member call chained onto a bare call's result is still a bare call, and a * wrapping await doesn't change the kind.