Skip to content

Commit 87e0822

Browse files
committed
fix(observability-map): stop a switch's break cutting the statements after it
definitelyExits read a bare break or continue as leaving the statement list wherever it found one, and both target the nearest enclosing construct of their kind instead. A switch whose clauses all break falls through to the statement written after it, so cutting that statement made catch (e) { switch (e.code) { ...break } throw e; } read as a swallow, failing a route that rethrows with a detail line saying it takes one way out regardless of what was thrown. Same for a do body that breaks or continues. definitelyExits now carries which bare jumps escape at that point in the recursion: a switch clause drops break and inherits continue (continue targets an enclosing loop, which the switch cannot be, so dropping it would stop a genuinely dead throw being cut), a do body drops both, and a labelled jump always counts. reachableStatements wraps its findIndex callback, which was passing an index where the jumps record now goes. The real route tree is byte-identical, report and clause evidence both: nothing in apps/webapp writes this shape today. dead-throw-after-switch-break is the corpus guard for the other direction, a clause that returns and also breaks, which still has to be cut.
1 parent 023bc03 commit 87e0822

4 files changed

Lines changed: 236 additions & 20 deletions

File tree

internal-packages/observability-map/src/checks/index.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,61 @@ describe("error-classification", () => {
741741
);
742742
expect(r.status).toBe("fail");
743743
});
744+
745+
// The verdict end of the `break and continue inside the construct they target` finding. A clause
746+
// that sorts the error by code and then rethrows was failed, with a detail line asserting it
747+
// takes one way out regardless of what was thrown, which is the opposite of what it does. Both
748+
// spellings are here because the pair is the evidence: the switch must not change the verdict.
749+
const SORTED_RETHROW = (sorter: string) => `import { prisma } from "~/db.server";
750+
export async function action({ request, params }) {
751+
try {
752+
return json(await prisma.thing.update({ where: { id: params.id }, data: {} }));
753+
} catch (e) {
754+
${sorter}
755+
throw e;
756+
}
757+
}`;
758+
759+
it("does not accuse a clause that sorts the error by code and rethrows", () => {
760+
const r = run(
761+
"error-classification",
762+
"api.v1.sorted.ts",
763+
SORTED_RETHROW(
764+
'switch (e.code) { case "P2025": handleNotFound(e); break; default: handleOther(e); break; }'
765+
)
766+
);
767+
expect(r.status).toBe("not-applicable");
768+
expect(r.detail).not.toContain("one way out");
769+
});
770+
771+
it("reads the same clause written without the switch identically", () => {
772+
const withSwitch = run(
773+
"error-classification",
774+
"api.v1.sorted.ts",
775+
SORTED_RETHROW(
776+
'switch (e.code) { case "P2025": handleNotFound(e); break; default: handleOther(e); break; }'
777+
)
778+
);
779+
const without = run(
780+
"error-classification",
781+
"api.v1.sorted.ts",
782+
SORTED_RETHROW("handleOther(e);")
783+
);
784+
expect(withSwitch).toEqual(without);
785+
});
786+
787+
// The other direction, so the rule above is not just "a switch is ignored": the same sorter with
788+
// a clause that answers the request is a decision, and still passes.
789+
it("still passes a clause whose switch on the error code answers the request", () => {
790+
const r = run(
791+
"error-classification",
792+
"api.v1.sorted.ts",
793+
SORTED_RETHROW(
794+
'switch (e.code) { case "P2025": return new Response(null, { status: 404 }); default: break; }'
795+
)
796+
);
797+
expect(r.status).toBe("pass");
798+
});
744799
});
745800

746801
describe("auth-boundary", () => {

internal-packages/observability-map/src/mutations.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,11 @@ function containsLooseJump(node: ts.Node): boolean {
304304
* Only applied to a clause whose statements already end in a `return` or a `throw`, so the appended
305305
* throw really is unreachable, and never to one holding a loose `break` or `continue`, which a `do`
306306
* or a `switch` would capture.
307+
*
308+
* `dead-throw-after-switch-break` guards the opposite direction of the same rule. A `break` in a
309+
* switch clause is no longer read as leaving the statement list the switch sits in, and the cheap
310+
* way to write that is "a clause holding a break does not exit", which would take this whole family
311+
* back: the clause here returns AND breaks, and the return is what has to win.
307312
*/
308313
function deadThrowAfter(id: string, what: string, wrap: (body: string) => string): Mutation {
309314
return {
@@ -763,6 +768,11 @@ export const MUTATIONS: Mutation[] = [
763768
"wrap every catch body in a switch default and write throw e; after it",
764769
(body) => `switch (1) { default: {\n${body}\n} }`
765770
),
771+
deadThrowAfter(
772+
"dead-throw-after-switch-break",
773+
"wrap every catch body in a switch default that also breaks and write throw e; after it",
774+
(body) => `switch (1) { default: {\n${body}\n}\nbreak; }`
775+
),
766776
deadThrowAfter(
767777
"dead-throw-after-try-finally",
768778
"wrap every catch body in try { ... } finally { } and write throw e; after it",
@@ -879,6 +889,7 @@ export const ADDITIVE_IDS = [
879889
"dead-throw-after-if-true",
880890
"dead-throw-after-if-else",
881891
"dead-throw-after-switch",
892+
"dead-throw-after-switch-break",
882893
"dead-throw-after-try-finally",
883894
"wrap-body-in-rethrow",
884895
"wrap-body-in-same-arms-throw-ternary",

internal-packages/observability-map/src/scan.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,121 @@ describe("scanFile: catch clause evidence", () => {
887887
});
888888
});
889889

890+
// S3. `definitelyExits` counted a bare `break` and a bare `continue` wherever it found one, and
891+
// both of those target the nearest enclosing construct of their kind rather than the statement
892+
// list the question is about. A `switch` whose clauses all break falls through to the statement
893+
// written after it, so cutting that statement as unreachable accused a route of swallowing an
894+
// error it rethrows, with a detail line saying it "takes one way out regardless of what was
895+
// thrown" about a clause that takes the same way out it arrived by. This is the false-accusation
896+
// direction, so both halves are pinned: what must now stay reachable, and what must still be cut.
897+
describe("break and continue inside the construct they target", () => {
898+
const clause = (body: string) => `
899+
export async function loader() {
900+
try {
901+
return await prisma.thing.findMany();
902+
} catch (e) {
903+
${body}
904+
}
905+
}
906+
`;
907+
908+
// Reachable, so the throw after them is a real rethrow. Each jump targets the construct it is
909+
// written in, and every one of these constructs falls through to the next statement.
910+
const FALLS_THROUGH: Array<[string, string]> = [
911+
[
912+
"a switch whose clauses all break",
913+
'switch (e.code) { case "P2025": handleNotFound(); break; default: handleOther(); break; }',
914+
],
915+
["a switch whose default is a bare break", "switch (e.code) { default: break; }"],
916+
["a do body that breaks", "do { break; } while (false);"],
917+
["a do body that continues", "do { continue; } while (false);"],
918+
[
919+
"a do body whose if/else both break",
920+
"do { if (pick()) { break; } else { break; } } while (false);",
921+
],
922+
];
923+
924+
for (const [label, wrapped] of FALLS_THROUGH) {
925+
it(`still sets rethrows for a throw written after ${label}`, () => {
926+
const ep = scanFile("x.ts", clause(`${wrapped}\nthrow e;`));
927+
expect(ep!.catches[0]!.rethrows).toBe(true);
928+
});
929+
930+
it(`still credits an error test written after ${label}`, () => {
931+
const ep = scanFile(
932+
"x.ts",
933+
clause(`${wrapped}\nif (e instanceof Error) { return json({ a: 1 }); }\nthrow e;`)
934+
);
935+
expect(ep!.catches[0]!.branches).toBe(true);
936+
});
937+
}
938+
939+
// The clause the whole finding was about, end to end: sorting the error by code and then
940+
// rethrowing is a rethrow, which is `not-applicable`, and never a swallow.
941+
it("reads a switch on the error code followed by a rethrow as a rethrow", () => {
942+
const ep = scanFile(
943+
"x.ts",
944+
clause(
945+
'switch (e.code) { case "P2025": handleNotFound(); break; default: handleOther(); break; }\nthrow e;'
946+
)
947+
);
948+
expect(ep!.catches[0]).toMatchObject({ rethrows: true, throws: true, branches: false });
949+
});
950+
951+
// Cut, so the throw after them is dead and must not be credited. The first two are the
952+
// over-correction control: a clause that returns and also breaks still exits, and reading the
953+
// break as "no exit" would take the whole `dead-throw-after-*` family back.
954+
const EXITS: Array<[string, string]> = [
955+
[
956+
"a switch clause that returns before it breaks",
957+
"switch (1) { default: { return null; } break; }",
958+
],
959+
[
960+
"a switch whose every clause returns",
961+
"switch (e.code) { case 1: return null; default: return 0; }",
962+
],
963+
["a do body that returns before it breaks", "do { return null; break; } while (false);"],
964+
];
965+
966+
for (const [label, wrapped] of EXITS) {
967+
it(`does not set rethrows for a throw written after ${label}`, () => {
968+
const ep = scanFile("x.ts", clause(`${wrapped}\nthrow e;`));
969+
expect(ep!.catches[0]!.rethrows).toBe(false);
970+
});
971+
}
972+
973+
// A `continue` in a switch clause targets the enclosing loop, not the switch, so it is
974+
// inherited through the clause rather than dropped with the `break`. Dropping it would leave
975+
// the throw below reachable, and it is not: the continue goes to the `do`'s condition.
976+
// The labelled jumps beside it leave the `for` entirely, so they are exits wherever they are
977+
// written. The bare `break` is the control that separates the three.
978+
const IN_LOOP: Array<[string, boolean]> = [
979+
["break outer", false],
980+
["continue outer", false],
981+
["continue", false],
982+
["break", true],
983+
];
984+
985+
for (const [jump, rethrows] of IN_LOOP) {
986+
it(`reads a switch clause that says ${jump} inside a labelled loop as rethrows=${rethrows}`, () => {
987+
const ep = scanFile(
988+
"x.ts",
989+
`export async function loader() {
990+
outer: for (const x of items) {
991+
try { await service.call(x); }
992+
catch (e) {
993+
switch (e.code) { case 1: ${jump}; default: ${jump}; }
994+
throw e;
995+
}
996+
}
997+
return null;
998+
}`
999+
);
1000+
expect(ep!.catches[0]!.rethrows).toBe(rethrows);
1001+
});
1002+
}
1003+
});
1004+
8901005
// S2. A clause whose try block cannot throw is unreachable, so it is not error handling and
8911006
// nothing should be read off it. Crediting one was the largest hole ever found here: prepending
8921007
// this to a body takes the real tree from 19 to 44 and raises 224 routes, because the routes

internal-packages/observability-map/src/scan.ts

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,21 @@ function selectsAnErrorPath(node: ts.ConditionalExpression, bindingName: string
468468
return normalizedText(unwrap(node.whenTrue)) !== normalizedText(unwrap(node.whenFalse));
469469
}
470470

471+
/**
472+
* Which bare (unlabelled) jumps, at this point in the recursion, leave the statement list the
473+
* question is being asked about. A bare jump targets the nearest enclosing construct of its kind,
474+
* so descending past one of those targets changes the answer for the jumps it captures.
475+
*/
476+
type BareJumps = { break: boolean; continue: boolean };
477+
478+
/** A jump written directly in the list under question always leaves it: whatever it targets
479+
* encloses the list. */
480+
const ESCAPES: BareJumps = { break: true, continue: true };
481+
482+
/** A `do` body, asked about from the list the `do` sits in. `break` ends the loop and `continue`
483+
* goes to the condition, and both of those reach the statement written after the `do`. */
484+
const IN_DO_BODY: BareJumps = { break: false, continue: false };
485+
471486
/**
472487
* A statement that leaves the statement list it sits in on every path through itself, so anything
473488
* after it in the same list never runs.
@@ -477,52 +492,72 @@ function selectsAnErrorPath(node: ts.ConditionalExpression, bindingName: string
477492
* it was a block, a `do` body or an `if`/`else` that returned; `dead-throw-after-*` in the mutation
478493
* corpus is that family, and `scan.test.ts` has one case per construct.
479494
*
495+
* A bare `break` or `continue` only counts where it actually leaves the list, which is what `jumps`
496+
* carries. A `break` inside a switch clause targets the switch, so a switch whose clauses all break
497+
* falls through to the statement after it and does NOT exit; reading that break as an exit accused
498+
* `catch (e) { switch (e.code) { ... break; } throw e; }` of swallowing a rethrown error, which is
499+
* both a false verdict and a detail line that says the opposite of what the route does. A `continue`
500+
* inside a switch clause targets an enclosing loop instead, which the switch cannot be, so it is
501+
* inherited rather than dropped: dropping it would stop
502+
* `do { switch (x) { default: continue; } throw e; } while (c)` cutting a throw that really is dead.
503+
* `break and continue inside the construct they target` in `scan.test.ts` holds both halves.
504+
*
505+
* A labelled `break`/`continue` always counts. Its target has to enclose the statement list, since
506+
* nothing between the list and the jump can carry the label: `definitelyExits` answers false for a
507+
* labelled statement, so the recursion never descends through one.
508+
*
480509
* A sound under-approximation. `if` without an `else`, a labelled statement (a `break` to the label
481510
* escapes it) and every other loop form answer false, because none of them is guaranteed to run its
482-
* body. Saying false when the truth is true only leaves a later statement in the list, which is the
483-
* direction that withholds evidence rather than inventing it.
511+
* body. That extends to a `do` that never falls through, `do { continue; } while (true)`, which is
512+
* now false for the same reason `while (true) { }` always was: separating it from
513+
* `do { continue; } while (c)` means folding the condition, which the dead-code defence deliberately
514+
* does not do. Saying false when the truth is true only leaves a later statement in the list, which
515+
* is the direction that withholds evidence rather than inventing it.
484516
*/
485-
function definitelyExits(statement: ts.Statement): boolean {
486-
if (
487-
ts.isReturnStatement(statement) ||
488-
ts.isThrowStatement(statement) ||
489-
ts.isContinueStatement(statement) ||
490-
ts.isBreakStatement(statement)
491-
) {
492-
return true;
517+
function definitelyExits(statement: ts.Statement, jumps: BareJumps = ESCAPES): boolean {
518+
if (ts.isReturnStatement(statement) || ts.isThrowStatement(statement)) return true;
519+
if (ts.isBreakStatement(statement)) return statement.label !== undefined || jumps.break;
520+
if (ts.isContinueStatement(statement)) return statement.label !== undefined || jumps.continue;
521+
if (ts.isBlock(statement)) {
522+
return statement.statements.some((s) => definitelyExits(s, jumps));
493523
}
494-
if (ts.isBlock(statement)) return statement.statements.some(definitelyExits);
495524
// A `do` body runs before its condition is ever read.
496-
if (ts.isDoStatement(statement)) return definitelyExits(statement.statement);
525+
if (ts.isDoStatement(statement)) return definitelyExits(statement.statement, IN_DO_BODY);
497526
if (ts.isIfStatement(statement)) {
498527
return (
499528
statement.elseStatement !== undefined &&
500-
definitelyExits(statement.thenStatement) &&
501-
definitelyExits(statement.elseStatement)
529+
definitelyExits(statement.thenStatement, jumps) &&
530+
definitelyExits(statement.elseStatement, jumps)
502531
);
503532
}
504533
if (ts.isTryStatement(statement)) {
505-
if (statement.finallyBlock && definitelyExits(statement.finallyBlock)) return true;
506-
if (!definitelyExits(statement.tryBlock)) return false;
507-
return statement.catchClause === undefined || definitelyExits(statement.catchClause.block);
534+
if (statement.finallyBlock && definitelyExits(statement.finallyBlock, jumps)) return true;
535+
if (!definitelyExits(statement.tryBlock, jumps)) return false;
536+
return (
537+
statement.catchClause === undefined || definitelyExits(statement.catchClause.block, jumps)
538+
);
508539
}
509540
if (ts.isSwitchStatement(statement)) {
541+
const inClause: BareJumps = { break: false, continue: jumps.continue };
510542
const clauses = statement.caseBlock.clauses;
511543
const last = clauses[clauses.length - 1];
512544
if (!clauses.some(ts.isDefaultClause) || last === undefined) return false;
513545
// An empty clause falls through to the next one, so it does not have to exit itself; the last
514546
// clause has nothing to fall through to and does.
515547
return (
516-
clauses.every((c) => c.statements.length === 0 || c.statements.some(definitelyExits)) &&
517-
last.statements.some(definitelyExits)
548+
clauses.every(
549+
(c) => c.statements.length === 0 || c.statements.some((s) => definitelyExits(s, inClause))
550+
) && last.statements.some((s) => definitelyExits(s, inClause))
518551
);
519552
}
520553
return false;
521554
}
522555

523556
/** `statements` up to and including the first one that definitely exits. */
524557
function reachableStatements(statements: readonly ts.Statement[]): readonly ts.Statement[] {
525-
const index = statements.findIndex(definitelyExits);
558+
// The arrow matters: `findIndex` passes an index as the second argument, which `definitelyExits`
559+
// would read as its `jumps` record.
560+
const index = statements.findIndex((s) => definitelyExits(s));
526561
return index === -1 ? statements : statements.slice(0, index + 1);
527562
}
528563

0 commit comments

Comments
 (0)