@fjall/eslint-plugin 9.0.0 → 10.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -28,6 +28,7 @@ import noClassicConnectedAccountAssume from "./no-classic-connected-account-assu
28
28
  import noRawDbTransaction from "./no-raw-db-transaction.js";
29
29
  import noRawExitCode from "./no-raw-exit-code.js";
30
30
  import noReplacementStringExpansion from "./no-replacement-string-expansion.js";
31
+ import noSilentResultDiscard from "./no-silent-result-discard.js";
31
32
 
32
33
  export default {
33
34
  rules: {
@@ -62,6 +63,7 @@ export default {
62
63
  "no-classic-connected-account-assume": noClassicConnectedAccountAssume,
63
64
  "no-raw-db-transaction": noRawDbTransaction,
64
65
  "no-raw-exit-code": noRawExitCode,
65
- "no-replacement-string-expansion": noReplacementStringExpansion
66
+ "no-replacement-string-expansion": noReplacementStringExpansion,
67
+ "no-silent-result-discard": noSilentResultDiscard
66
68
  }
67
69
  };
@@ -0,0 +1,228 @@
1
+ /**
2
+ * ESLint Rule: no-silent-result-discard
3
+ *
4
+ * A `Result` failure branch that neither propagates, throws, records, nor
5
+ * even reads the error silently conflates "could not read" with "empty" —
6
+ * the fail-open shape behind the secrets fail-open incident (a denied SSM
7
+ * read merged as an empty namespace level). Read-failure posture: value,
8
+ * creation and injection reads fail closed; availability-class reads may
9
+ * degrade only via an explicit, visible note; genuine absence is
10
+ * discriminated by error name (`errorType === "not_found"`,
11
+ * `isXNotFoundError`), never assumed from an undiscriminated failure.
12
+ *
13
+ * The rule flags only failure branches that are TRIVIALLY silent — every
14
+ * statement is a bare `continue`/`break`/`;` or a `return` of nothing/an
15
+ * empty literal (`null`, `undefined`, `[]`, `{}`, `""`, `false`, `0`,
16
+ * including TS-wrapped forms like `[] as T[]` and `void 0`), or the branch
17
+ * is empty. Any call, throw, `.error` read, or non-empty return in the
18
+ * branch counts as recording/propagating and passes, which keeps the
19
+ * false-positive rate near zero. A non-empty literal return
20
+ * (`return "unknown"`) is a semantic degraded verdict — the sanctioned
21
+ * degrade-with-visible-note pattern — and passes for the same reason.
22
+ *
23
+ * Known limitations (deliberate, matching the plugin's untyped design):
24
+ * - Discards routed through helper indirection
25
+ * (`if (isFailure(x)) return handleQuietly(x)`) pass — any call counts.
26
+ * - `if (x.success) { ... }` with NO else (the drop-by-omission shape) is
27
+ * not flagged; it is indistinguishable from optional enrichment without
28
+ * type information.
29
+ * - A dropped return value of a Result-returning call needs parserServices
30
+ * and is out of scope for this untyped rule.
31
+ * - Optional-chained tests (`x?.success`, either polarity) are skipped —
32
+ * they double as null-guards.
33
+ * - Guard recognition is an exact-shape allowlist; unrecognised test shapes
34
+ * pass unexamined — e.g. computed access (`result["success"]`), loose
35
+ * equality, and `x.success !== false` as a positive guard (only
36
+ * `x.success` / `isSuccess(x)` / `=== true` classify the else arm as the
37
+ * failure branch).
38
+ *
39
+ * Escape hatch for genuine failure-means-absence sites:
40
+ * `// eslint-disable-next-line fjall/no-silent-result-discard -- <why>`
41
+ */
42
+
43
+ /**
44
+ * `x.success` member read (non-computed, non-optional).
45
+ */
46
+ function isSuccessMember(node) {
47
+ return (
48
+ node.type === "MemberExpression" &&
49
+ !node.computed &&
50
+ !node.optional &&
51
+ node.property.type === "Identifier" &&
52
+ node.property.name === "success"
53
+ );
54
+ }
55
+
56
+ /** `isFailure(x)` / `isSuccess(x)` helper call. */
57
+ function isResultHelperCall(node, helperName) {
58
+ return (
59
+ node.type === "CallExpression" &&
60
+ node.callee.type === "Identifier" &&
61
+ node.callee.name === helperName &&
62
+ node.arguments.length === 1
63
+ );
64
+ }
65
+
66
+ /** `<lit> === x.success` / `x.success === <lit>` — returns the literal value. */
67
+ function successComparisonLiteral(node, operator) {
68
+ if (node.type !== "BinaryExpression" || node.operator !== operator) {
69
+ return undefined;
70
+ }
71
+ const { left, right } = node;
72
+ if (isSuccessMember(left) && right.type === "Literal") return right.value;
73
+ if (isSuccessMember(right) && left.type === "Literal") return left.value;
74
+ return undefined;
75
+ }
76
+
77
+ /**
78
+ * Classify which arm of the IfStatement is the FAILURE branch.
79
+ * Returns "consequent", "alternate", or null when the test is not a
80
+ * recognised Result-success guard.
81
+ */
82
+ function failureBranchOf(test) {
83
+ // !x.success / !isSuccess(x)
84
+ if (test.type === "UnaryExpression" && test.operator === "!") {
85
+ if (
86
+ isSuccessMember(test.argument) ||
87
+ isResultHelperCall(test.argument, "isSuccess")
88
+ ) {
89
+ return "consequent";
90
+ }
91
+ return null;
92
+ }
93
+ // isFailure(x)
94
+ if (isResultHelperCall(test, "isFailure")) return "consequent";
95
+ // x.success === false / false === x.success
96
+ if (successComparisonLiteral(test, "===") === false) return "consequent";
97
+ // x.success !== true / true !== x.success
98
+ if (successComparisonLiteral(test, "!==") === true) return "consequent";
99
+ // x.success / isSuccess(x) / x.success === true → failure arm is the else
100
+ if (
101
+ isSuccessMember(test) ||
102
+ isResultHelperCall(test, "isSuccess") ||
103
+ successComparisonLiteral(test, "===") === true
104
+ ) {
105
+ return "alternate";
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Strip TS type-level wrappers (`as`, `satisfies`, `!`, `<T>`) and `void`
112
+ * so `return [] as T[]` / `return void 0` cannot evade the emptyish check.
113
+ * `void <call>` unwraps to the call, which is not emptyish, so a
114
+ * `return void logger.warn(...)` still counts as recording.
115
+ */
116
+ function unwrapTypeExpressions(node) {
117
+ let current = node;
118
+ for (;;) {
119
+ if (
120
+ current.type === "TSAsExpression" ||
121
+ current.type === "TSSatisfiesExpression" ||
122
+ current.type === "TSNonNullExpression" ||
123
+ current.type === "TSTypeAssertion"
124
+ ) {
125
+ current = current.expression;
126
+ } else if (
127
+ current.type === "UnaryExpression" &&
128
+ current.operator === "void"
129
+ ) {
130
+ current = current.argument;
131
+ } else {
132
+ return current;
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Nothing, or a silent-empty literal: null/undefined/""/0/false/[]/{}.
139
+ * A NON-empty literal (`return "unknown"`, `return "unprovable"`) is a
140
+ * semantic verdict — the degrade-with-visible-note pattern — and passes.
141
+ */
142
+ function isEmptyishReturnArgument(rawArgument) {
143
+ if (!rawArgument) return true;
144
+ const argument = unwrapTypeExpressions(rawArgument);
145
+ if (argument.type === "Literal") {
146
+ return (
147
+ argument.value === null ||
148
+ argument.value === false ||
149
+ argument.value === 0 ||
150
+ argument.value === ""
151
+ );
152
+ }
153
+ if (argument.type === "Identifier" && argument.name === "undefined") {
154
+ return true;
155
+ }
156
+ if (
157
+ argument.type === "TemplateLiteral" &&
158
+ argument.expressions.length === 0
159
+ ) {
160
+ return true;
161
+ }
162
+ if (argument.type === "ArrayExpression" && argument.elements.length === 0) {
163
+ return true;
164
+ }
165
+ if (
166
+ argument.type === "ObjectExpression" &&
167
+ argument.properties.length === 0
168
+ ) {
169
+ return true;
170
+ }
171
+ return false;
172
+ }
173
+
174
+ /**
175
+ * True when the branch discards the failure without any trace: empty, or
176
+ * only bare continue/break/empty-ish returns. The statement whitelist
177
+ * structurally excludes calls, throws and `.error` reads, so any of those
178
+ * makes the branch pass.
179
+ */
180
+ function isTriviallySilent(branch) {
181
+ const statements = branch.type === "BlockStatement" ? branch.body : [branch];
182
+ for (const statement of statements) {
183
+ if (statement.type === "ReturnStatement") {
184
+ if (!isEmptyishReturnArgument(statement.argument)) return false;
185
+ } else if (
186
+ statement.type !== "ContinueStatement" &&
187
+ statement.type !== "BreakStatement" &&
188
+ statement.type !== "EmptyStatement"
189
+ ) {
190
+ return false;
191
+ }
192
+ }
193
+ return true;
194
+ }
195
+
196
+ /** @type {import('eslint').Rule.RuleModule} */
197
+ export default {
198
+ meta: {
199
+ type: "problem",
200
+ docs: {
201
+ description:
202
+ "Disallow Result failure branches that silently discard the error",
203
+ category: "Best Practices",
204
+ recommended: true
205
+ },
206
+ messages: {
207
+ silentDiscard:
208
+ 'This failure branch drops the Result error without a trace, conflating "could not read" with "empty". Propagate the failure, discriminate genuine absence by error name (errorType === "not_found" / isXNotFoundError), or record the degradation visibly (progress.warning / logger.warn). If failure genuinely means absence here, keep it with an eslint-disable comment and a -- justification (read-failure posture, robustness-standards).'
209
+ },
210
+ schema: []
211
+ },
212
+
213
+ create(context) {
214
+ return {
215
+ IfStatement(node) {
216
+ const arm = failureBranchOf(node.test);
217
+ if (arm === null) return;
218
+ const branch = arm === "consequent" ? node.consequent : node.alternate;
219
+ if (!branch) return;
220
+ // `else if` chains are their own IfStatement visit.
221
+ if (branch.type === "IfStatement") return;
222
+ if (isTriviallySilent(branch)) {
223
+ context.report({ node: branch, messageId: "silentDiscard" });
224
+ }
225
+ }
226
+ };
227
+ }
228
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/eslint-plugin",
3
- "version": "9.0.0",
3
+ "version": "10.1.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",