Skip to content

Commit 569a111

Browse files
committed
fix(observability-map): stop crediting an if or switch that never looks at the error
catchClauseEvidence counted any if/switch inside a catch as branching, whether or not its condition read the caught error. catch (_e) { if (organization) ... } and catch (error: any) { if (wantsJson) ... } both counted, crediting a route that always takes the same path regardless of what was thrown. The if condition (or switch discriminant) must now reference the caught error binding; a bindingless catch { ... } cannot qualify at all. Ternary handling is unchanged. Measured on the real tree: 5 of 242 catch clauses lose branches, 4 routes flip from pass to fail on error-classification, and the global score moves from 18 to 17. Hand-read all 4: each was a false positive, branching on an unrelated local rather than the error.
1 parent 37d5f1a commit 569a111

2 files changed

Lines changed: 127 additions & 5 deletions

File tree

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

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,28 @@ function guardsParse(tryBlock: ts.Block): boolean {
153153
return found;
154154
}
155155

156+
/** Whether some node in the tree rooted at `node` matches `predicate`. */
157+
function someNode(node: ts.Node, predicate: (n: ts.Node) => boolean): boolean {
158+
if (predicate(node)) return true;
159+
return ts.forEachChild(node, (child) => someNode(child, predicate)) === true;
160+
}
161+
156162
function containsInstanceOf(node: ts.Node): boolean {
157-
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword) {
158-
return true;
159-
}
160-
return ts.forEachChild(node, containsInstanceOf) === true;
163+
return someNode(
164+
node,
165+
(n) => ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword
166+
);
167+
}
168+
169+
/** Whether `node` reads the given catch binding anywhere, e.g. `e` in `e instanceof X` or `error.code`. */
170+
function referencesBinding(node: ts.Node, bindingName: string): boolean {
171+
return someNode(node, (n) => ts.isIdentifier(n) && n.text === bindingName);
172+
}
173+
174+
/** The catch binding's name, or null for a bindingless `catch { ... }` or a destructured one. */
175+
function catchBindingName(clause: ts.CatchClause): string | null {
176+
const decl = clause.variableDeclaration;
177+
return decl && ts.isIdentifier(decl.name) ? decl.name.text : null;
161178
}
162179

163180
/**
@@ -177,10 +194,17 @@ function selectsAnErrorPath(node: ts.ConditionalExpression): boolean {
177194
function catchClauseEvidence(clause: ts.CatchClause): { rethrows: boolean; branches: boolean } {
178195
let rethrows = false;
179196
let branches = false;
197+
const bindingName = catchBindingName(clause);
180198

181199
const visit = (node: ts.Node) => {
182200
if (ts.isThrowStatement(node)) rethrows = true;
183-
if (ts.isIfStatement(node) || ts.isSwitchStatement(node)) branches = true;
201+
if (
202+
bindingName !== null &&
203+
((ts.isIfStatement(node) && referencesBinding(node.expression, bindingName)) ||
204+
(ts.isSwitchStatement(node) && referencesBinding(node.expression, bindingName)))
205+
) {
206+
branches = true;
207+
}
184208
if (ts.isConditionalExpression(node) && selectsAnErrorPath(node)) branches = true;
185209
ts.forEachChild(node, visit);
186210
};

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1289,3 +1289,101 @@ describe("scanFile: branches ignores the error-stringifying ternary", () => {
12891289
expect(ep!.catches[0]!.branches).toBe(true);
12901290
});
12911291
});
1292+
1293+
describe("scanFile: branches requires the if/switch condition to examine the error", () => {
1294+
it("does not set branches for an `if` on an unrelated variable", () => {
1295+
const ep = scanFile(
1296+
"retry-count.ts",
1297+
`
1298+
export async function action({ request }) {
1299+
let attempt = 0;
1300+
try {
1301+
return json(await load(request));
1302+
} catch (e) {
1303+
if (attempt > 3) return json({}, { status: 503 });
1304+
return json({}, { status: 500 });
1305+
}
1306+
}
1307+
`
1308+
);
1309+
expect(ep!.catches[0]!.branches).toBe(false);
1310+
});
1311+
1312+
it("sets branches for an `if` whose condition references the caught error", () => {
1313+
const ep = scanFile(
1314+
"branch-if-e.ts",
1315+
`
1316+
export async function loader({ request }) {
1317+
try {
1318+
return json(await load(request));
1319+
} catch (e) {
1320+
if (e instanceof ApiError) return json({}, { status: e.status });
1321+
return json({}, { status: 500 });
1322+
}
1323+
}
1324+
`
1325+
);
1326+
expect(ep!.catches[0]!.branches).toBe(true);
1327+
});
1328+
1329+
it("sets branches for a `switch` on a property of the caught error", () => {
1330+
const ep = scanFile(
1331+
"branch-switch-error-code.ts",
1332+
`
1333+
export async function loader({ request }) {
1334+
try {
1335+
return json(await load(request));
1336+
} catch (error) {
1337+
switch (error.code) {
1338+
case "P2025":
1339+
return json({}, { status: 404 });
1340+
default:
1341+
return json({}, { status: 500 });
1342+
}
1343+
}
1344+
}
1345+
`
1346+
);
1347+
expect(ep!.catches[0]!.branches).toBe(true);
1348+
});
1349+
1350+
it("does not set branches for a `switch` on an unrelated discriminant", () => {
1351+
const ep = scanFile(
1352+
"branch-switch-unrelated.ts",
1353+
`
1354+
export async function action({ request }) {
1355+
const mode = "strict";
1356+
try {
1357+
return json(await load(request));
1358+
} catch (error) {
1359+
switch (mode) {
1360+
case "strict":
1361+
return json({}, { status: 400 });
1362+
default:
1363+
return json({}, { status: 500 });
1364+
}
1365+
}
1366+
}
1367+
`
1368+
);
1369+
expect(ep!.catches[0]!.branches).toBe(false);
1370+
});
1371+
1372+
it("cannot set branches for a bindingless catch, even with an `if` inside it", () => {
1373+
const ep = scanFile(
1374+
"bindingless-if.ts",
1375+
`
1376+
export async function loader({ request }) {
1377+
let attempt = 0;
1378+
try {
1379+
return json(await load(request));
1380+
} catch {
1381+
if (attempt > 3) return json({}, { status: 503 });
1382+
return json({}, { status: 500 });
1383+
}
1384+
}
1385+
`
1386+
);
1387+
expect(ep!.catches[0]!.branches).toBe(false);
1388+
});
1389+
});

0 commit comments

Comments
 (0)