eslint-factory: require null-safe companion for typeof err === "object" catch guards#42568
eslint-factory: require null-safe companion for typeof err === "object" catch guards#42568Copilot wants to merge 3 commits into
typeof err === "object" catch guards#42568Conversation
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
typeof err === "object" catch guards
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
1 similar comment
|
Great work on this fix! 👋 Tightening the The PR is well-structured: the logic change, the test realignment (moving bare- This looks ready for reviewer eyes. 🚀
|
|
✅ Design Decision Gate 🏗️ completed the design decision gate check. No ADR enforcement needed: PR #42568 does not have the 'implementation' label and has 0 new lines of code in business logic directories (threshold: 100). |
|
✅ Test Quality Sentinel completed test quality analysis. |
|
🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅ |
|
|
There was a problem hiding this comment.
Pull request overview
Tightens the null-safety model in the eslint-factory rules that allow property access on caught/rejected values, ensuring typeof err === "object" is only treated as a safe guard when paired with a non-null/truthy signal (since null also satisfies typeof === "object").
Changes:
- Added non-null companion guard tracking (
hasNonNullGuard) and new guard-detection helpers to both unsafe-error-property rules. - Realigned tests so bare-
typeofcases are invalid and null-checked / truthy-checked variants remain valid. - Updated the generated
pr-triage-agentworkflow lockfile, bumping gh-aw-firewall container tags.
Show a summary per file
| File | Description |
|---|---|
| eslint-factory/src/rules/no-unsafe-promise-catch-error-property.ts | Adds non-null companion guard tracking and updates typeof guard semantics for .catch() callbacks. |
| eslint-factory/src/rules/no-unsafe-promise-catch-error-property.test.ts | Moves bare-typeof cases to invalid and adds valid null-safe variants for promise .catch(). |
| eslint-factory/src/rules/no-unsafe-catch-error-property.ts | Adds non-null companion guard tracking and updates typeof guard semantics for catch (err) blocks. |
| eslint-factory/src/rules/no-unsafe-catch-error-property.test.ts | Moves bare-typeof cases to invalid and adds valid null-safe variants for catch blocks. |
| .github/workflows/pr-triage-agent.lock.yml | Regenerates lockfile with updated gh-aw-firewall container tags. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 5/5 changed files
- Comments generated: 3
- Review effort level: Low
| if (isNonNullGuardCheck(node, top.varName)) { | ||
| top.hasNonNullGuard = true; | ||
| return; | ||
| } | ||
|
|
||
| if (isTypeofObjectCheck(node, top.varName) && top.hasNonNullGuard) { | ||
| top.hasGuard = true; | ||
| } |
| if (isNonNullGuardCheck(node, top.varName)) { | ||
| top.hasNonNullGuard = true; | ||
| return; | ||
| } | ||
|
|
||
| if (isTypeofObjectCheck(node, top.varName) && top.hasNonNullGuard) { | ||
| top.hasGuard = true; | ||
| } |
| @@ -1,5 +1,5 @@ | |||
| # gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"13b159b3008ccce2d9c7233e7bfa0c5eea0583c57bd721b9ee9a33efa43bae5f","body_hash":"8d505062b276bc9c6fda2b79316117cb30c88ae04cab333ef78d113bf91c6aec","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.65","copilot-sdk":"1.0.4"}} | |||
| # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.15"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.15"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.15"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.15"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.32","digest":"sha256:63e46b56dfd70895a701b6fc6dd0189e11e2d875f327f1781e81b31848735477","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.32@sha256:63e46b56dfd70895a701b6fc6dd0189e11e2d875f327f1781e81b31848735477"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.5.0","digest":"sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4","pinned_image":"ghcr.io/github/github-mcp-server:v1.5.0@sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4"}]} | |||
| # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.16"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.16"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.16"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.16"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.32","digest":"sha256:63e46b56dfd70895a701b6fc6dd0189e11e2d875f327f1781e81b31848735477","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.32@sha256:63e46b56dfd70895a701b6fc6dd0189e11e2d875f327f1781e81b31848735477"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.5.0","digest":"sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4","pinned_image":"ghcr.io/github/github-mcp-server:v1.5.0@sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4"}]} | |||
Test Quality Sentinel ReportScore: 94/100 - Excellent (TypeScript/vitest tests analyzed directly from branch; .test.ts not captured by pre-fetch step) Analyzed 5 tests across 2 files: 5 design tests (100%), 0 implementation tests (0%), 4 with edge/error cases (80%), 0 duplicate clusters, no test inflation, 0 guideline violations. Test classification summary:
Test inflation: +47 test vs +74 prod (ratio 0.63); +24 test vs +75 prod (ratio 0.32). Both well under 2:1. Verdict: Check passed. 0% implementation tests (threshold 30%). No violations. All tests enforce behavioral contracts confirming bare typeof without null-safe companion is rejected, and 5 null-check variants are accepted. Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
There was a problem hiding this comment.
Review: eslint-factory null-safe typeof guard tightening
Overall: The direction is correct and important — bare typeof err === "object" genuinely allows null through, and this PR closes that unsound path. The LogicalExpression conjunct collector and the !err return early-exit pattern are solid. Three issues need addressing before merge.
🔴 Blocking
1. IfStatement handler misses block-body early return (see inline comment on line 173)
if (!err) { return; } — the common braced form — is not recognized as a non-null guard because the consequent is a BlockStatement, not a bare ReturnStatement. This produces a false negative that counterparts the feature being introduced. The same gap is in no-unsafe-promise-catch-error-property.ts.
🟡 Non-blocking
2. Helper functions duplicated verbatim (see inline comment on promise rule line 36)
isTypeofObjectCheck and isNonNullGuardCheck are copied word-for-word into both rule files. Extracting them to a shared guard-helpers.ts prevents future divergence.
3. BinaryExpression cross-block null-check false negative (see inline comment on catch rule line 135)
A standalone if (err !== null) { doX(); } followed by a separate if (typeof err === 'object') { err.status } is incorrectly treated as safe. At minimum this deserves a known-limitations comment; ideally tighten to require the null-check to appear as a direct IfStatement test.
What's working well
LogicalExpressionconjunct flattening correctly handles all orderings oftypeof/null within the same expression.!err return(bare form) correctly setshasNonNullGuard.- Test suite is substantially improved; moving bare-
typeofcases toinvalidis the right call. - Both rules updated in parity.
🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · 59.5 AIC · ⌖ 8.65 AIC · ⊞ 4.9K
| const isNullLiteral = (value: TSESTree.Expression): value is TSESTree.Literal => value.type === AST_NODE_TYPES.Literal && value.value === null; | ||
|
|
||
| return (isVarRef(node.left) && isNullLiteral(node.right)) || (isVarRef(node.right) && isNullLiteral(node.left)); | ||
| } |
There was a problem hiding this comment.
Code duplication — extract shared helpers. isTypeofObjectCheck and isNonNullGuardCheck are copied verbatim from no-unsafe-catch-error-property.ts. Any future logic change will need to be applied in both files, making divergence likely.
Consider extracting them to a shared utility, e.g. eslint-factory/src/rules/guard-helpers.ts, then importing from both rules.
@copilot please address this.
| node.test.operator === "!" && | ||
| node.test.argument.type === AST_NODE_TYPES.Identifier && | ||
| node.test.argument.name === top.varName && | ||
| node.consequent.type === AST_NODE_TYPES.ReturnStatement |
There was a problem hiding this comment.
IfStatement handler misses block-body early return. node.consequent.type === AST_NODE_TYPES.ReturnStatement only matches bare if (!err) return;, but NOT the equally common if (!err) { return; } (a BlockStatement whose first/only statement is ReturnStatement).
Example that will NOT be recognized as a non-null guard today:
} catch (err) {
if (!err) { return; } // consequent is BlockStatement — missed!
if (typeof err === 'object') {
console.log(err.status); // false negative: rule stays silent
}
}Consider also matching:
node.consequent.type === AST_NODE_TYPES.BlockStatement &&
node.consequent.body.length === 1 &&
node.consequent.body[0].type === AST_NODE_TYPES.ReturnStatementThe same gap exists in no-unsafe-promise-catch-error-property.ts.
@copilot please address this.
| } | ||
|
|
||
| if (isTypeofObjectCheck(node, top.varName) && top.hasNonNullGuard) { | ||
| top.hasGuard = true; |
There was a problem hiding this comment.
Potential false negative: BinaryExpression cross-block null-check. The hasNonNullGuard flag is set for any err !== null expression seen anywhere in the catch block. This means the following will incorrectly be considered safe:
} catch (err) {
if (err !== null) {
doSomethingElse(); // null check scoped only here
}
// err might be null again here
if (typeof err === 'object') {
console.log(err.status); // false negative: hasNonNullGuard=true from above
}
}The LogicalExpression handler correctly handles in-expression conjuncts. For sequential statements, only the !err return pattern (via IfStatement) is truly scoped. The BinaryExpression path treats any null-check anywhere in the block as sufficient.
This is an edge case but worth documenting as a known limitation (e.g., a comment), or tightening by only allowing BinaryExpression-based null checks that appear as the direct test of an IfStatement (not inside another block).
@copilot please address this.
Review Summary 🧠Applied 🚨 Blocking: False negative in
|
There was a problem hiding this comment.
Skills-Based Review 🧠
Applied /diagnosing-bugs, /tdd, and /codebase-design — requesting changes on a correctness bug in the new guard detection logic.
📋 Key Themes & Highlights
Key Themes
-
False negative in
BinaryExpressionhandler (blocking): The newisNonNullGuardCheckbranch inBinaryExpressionsetshasNonNullGuard = truefor anyerr !== nullexpression seen in the catch frame — including standalone ones in separateifblocks. This causes a subsequent baretypeof err === 'object'to incorrectly pass as guarded, defeating the whole purpose of the PR. The inline&&case is fully covered byLogicalExpression; the early-return case byIfStatement. TheBinaryExpressionpath forisNonNullGuardCheckshould be removed. -
Missing regression test (blocking): There is no test asserting that
if (err !== null) { } if (typeof err === 'object') { err.status; }is invalid. Without it the false negative is undetectable and can regress silently. -
DRY violation (non-blocking):
isTypeofObjectCheckandisNonNullGuardCheckare copy-pasted identically into both rule files. Extract to a sharedguard-helpers.ts. -
IfStatementhandler gaps (non-blocking): Onlyreturnis recognized as an early exit (missingthrow); the visitor also fires for nestedifstatements that may not guard top-level code.
Positive Highlights
- ✅ The core insight is correct and important:
typeof null === 'object'makes baretypeofan unsound guard. - ✅ The
LogicalExpressionvisitor correctly flattens&&chains and handles all conjunct orderings. - ✅ The
!errearly-return pattern is a nice addition to the guard vocabulary. - ✅ Test names are updated to describe the actual invariant, not just the old behaviour.
- ✅ Both rules get symmetric treatment.
🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 96.5 AIC · ⌖ 8.35 AIC · ⊞ 6.6K
Comment /matt to run again
| left.value === "object"); | ||
| if (isTypeofObject) { | ||
| top.hasGuard = true; | ||
| if (isNonNullGuardCheck(node, top.varName)) { |
There was a problem hiding this comment.
[/diagnosing-bugs] This BinaryExpression path introduces a false negative: any standalone if (err !== null) { ... } in the catch block sets hasNonNullGuard = true for the whole frame, making a subsequent bare if (typeof err === 'object') incorrectly appear as fully guarded.
💡 Root cause & suggested fix
The pattern that would be incorrectly allowed:
catch (err) {
if (err !== null) { doSomething(); } // BinaryExpression → hasNonNullGuard = true
if (typeof err === 'object') { // sees hasNonNullGuard → sets hasGuard = true
console.log(err.status); // UNSAFE: null.status throws!
}
}The inline && case is already handled exclusively by LogicalExpression (by the time children's BinaryExpression fires, hasGuard is already true and the visitor early-returns). The isNonNullGuardCheck branch here is redundant for inline && and harmful for the standalone case.
Fix: remove the isNonNullGuardCheck branch from BinaryExpression:
BinaryExpression(node) {
// ...
// isNonNullGuardCheck removed — LogicalExpression covers inline &&,
// IfStatement sets hasNonNullGuard for the early-return pattern
if (isTypeofObjectCheck(node, top.varName) && top.hasNonNullGuard) {
top.hasGuard = true;
}
},@copilot please address this.
| left.value === "object"); | ||
| if (isTypeofObject) { | ||
| top.hasGuard = true; | ||
| if (isNonNullGuardCheck(node, top.varName)) { |
There was a problem hiding this comment.
[/diagnosing-bugs] Same false negative as in the catch rule: any standalone err !== null expression inside the .catch() callback sets hasNonNullGuard = true for the whole frame, incorrectly marking a subsequent bare typeof err === 'object' guard as safe.
💡 Fix
Remove the isNonNullGuardCheck branch from BinaryExpression here, mirroring the fix for no-unsafe-catch-error-property.ts. The LogicalExpression visitor already handles inline && patterns; IfStatement handles the early-return pattern.
@copilot please address this.
| }, | ||
| ], | ||
| }); | ||
| }); |
There was a problem hiding this comment.
[/tdd] The new invalid test block covers bare typeof but is missing a regression test for the BinaryExpression false negative: a standalone err !== null in a separate if should not guard a subsequent typeof check.
💡 Suggested test to add
it("invalid: err !== null in a separate if does not guard a subsequent typeof check", () => {
cjsRuleTester.run("no-unsafe-catch-error-property", noUnsafeCatchErrorPropertyRule, {
valid: [],
invalid: [
{
code: `try { f(); } catch (err) { if (err !== null) { } if (typeof err === 'object') { console.log(err.status); } }`,
errors: [
{
messageId: "unsafeProperty",
data: { prop: "status", errorVar: "err" },
},
],
},
],
});
});Without this, the fix to the BinaryExpression handler cannot be verified and the false negative can silently regress.
@copilot please address this.
| unsafeNodes: Array<{ node: TSESTree.MemberExpression; prop: string }>; | ||
| } | ||
|
|
||
| function isTypeofObjectCheck(node: TSESTree.Expression, varName: string): boolean { |
There was a problem hiding this comment.
[/codebase-design] isTypeofObjectCheck and isNonNullGuardCheck are copy-pasted verbatim into both rule files, creating a maintenance trap: any future change to guard semantics must be applied in two places.
💡 Suggested extraction
Extract to a shared module, e.g. eslint-factory/src/rules/guard-helpers.ts:
// guard-helpers.ts
import { AST_NODE_TYPES, TSESTree } from "`@typescript-eslint/utils`";
export function isTypeofObjectCheck(node: TSESTree.Expression, varName: string): boolean { ... }
export function isNonNullGuardCheck(node: TSESTree.Expression, varName: string): boolean { ... }Then import in each rule:
import { isTypeofObjectCheck, isNonNullGuardCheck } from "./guard-helpers";This also enables testing the helpers in isolation, which would directly expose edge cases like the false negative discussed in the BinaryExpression handler.
@copilot please address this.
| node.test.operator === "!" && | ||
| node.test.argument.type === AST_NODE_TYPES.Identifier && | ||
| node.test.argument.name === top.varName && | ||
| node.consequent.type === AST_NODE_TYPES.ReturnStatement |
There was a problem hiding this comment.
[/diagnosing-bugs] The early-exit check only recognises return; a throw statement is equally valid as a non-null escape hatch but won't set hasNonNullGuard.
💡 Suggested fix
const consequent = node.consequent;
const isEarlyExit =
consequent.type === AST_NODE_TYPES.ReturnStatement ||
consequent.type === AST_NODE_TYPES.ThrowStatement;
if (isEarlyExit) {
top.hasNonNullGuard = true;
}The same change is needed in no-unsafe-promise-catch-error-property.ts.
@copilot please address this.
| } | ||
| }, | ||
|
|
||
| IfStatement(node) { |
There was a problem hiding this comment.
[/diagnosing-bugs] The IfStatement visitor fires for any depth in the catch block, including if (!err) return; nested inside another branch. A deeply-nested early exit may not actually guard the top-level catch body, creating a potential false negative.
💡 Example & mitigation
catch (err) {
if (someCondition) {
if (!err) return; // only runs when someCondition is truthy
}
// err could still be falsy here — but hasNonNullGuard is now set!
if (typeof err === 'object') {
console.log(err.status); // incorrectly allowed
}
}Consider restricting hasNonNullGuard = true to IfStatement nodes whose parent is the CatchClause's BlockStatement directly (depth-1 check). This trades off some false-positive tolerance for correctness on the false-negative side.
@copilot please address this.
Both
no-unsafe-catch-error-propertyandno-unsafe-promise-catch-error-propertytreated baretypeof err === "object"as a safe guard, which is unsound becausenullsatisfies that check. This change tightens guard semantics sotypeofonly suppresses unsafe-property diagnostics when paired with a non-null/truthy guard.Guard semantics tightened in both rules
eslint-factory/src/rules/no-unsafe-catch-error-property.tseslint-factory/src/rules/no-unsafe-promise-catch-error-property.tstypeof err === "object"(or reversed form) now requires a companion non-null signal before setting guarded state:err !== nullerr != nullerr && typeof err === "object"if (!err) return;followed bytypeof err === "object"Existing guard behavior preserved
err instanceof Errorremains a valid guard.getErrorMessage(err)guard behavior is unchanged.Tests realigned with intended safety model
typeofcases fromvalidtoinvalidin catch and promise-catch suites.