proteum 2.5.16 → 2.5.17

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/eslint.js CHANGED
@@ -183,6 +183,8 @@ const hasOptionalCallBoundary = (callExpression, ancestors = []) => {
183
183
  return hasOptional || ancestors.some(({ node }) => node.type === 'ChainExpression');
184
184
  };
185
185
 
186
+ // A loop body is iteration, not a condition. `for (const item of batch) item.reject(error)`
187
+ // preserves the error for every item there is, so it is not a swallow.
186
188
  const isUnderConditionalControlFlow = (ancestors = []) =>
187
189
  ancestors.some(({ node, childKey }) => {
188
190
  if (node.type === 'IfStatement') return childKey === 'consequent' || childKey === 'alternate';
@@ -190,13 +192,33 @@ const isUnderConditionalControlFlow = (ancestors = []) =>
190
192
  if (node.type === 'LogicalExpression') return childKey === 'right';
191
193
  if (node.type === 'SwitchCase') return childKey === 'consequent';
192
194
 
193
- return (
194
- ['ForInStatement', 'ForOfStatement', 'ForStatement', 'WhileStatement', 'DoWhileStatement'].includes(
195
- node.type,
196
- ) && childKey === 'body'
197
- );
195
+ return false;
198
196
  });
199
197
 
198
+ /**
199
+ * Does the handler throw, whatever it throws?
200
+ *
201
+ * `catch { throw new Error('must be an absolute URL') }` translates a failure
202
+ * into the domain's own vocabulary. The original object is dropped, but the
203
+ * failure still propagates and nothing continues silently, which is what this
204
+ * rule exists to prevent. Attaching the original as `cause` is better practice,
205
+ * not a separate correctness question for this rule.
206
+ */
207
+ const handlerThrows = (node) => {
208
+ let throws = false;
209
+
210
+ traverseNode(node, (child, _parent, _parentKey, ancestors) => {
211
+ if (child.type === 'ThrowStatement' && !isUnderConditionalControlFlow(ancestors)) throws = true;
212
+ });
213
+
214
+ return throws;
215
+ };
216
+
217
+ // A returned fallback is deliberately NOT accepted. `catch { return null }` is
218
+ // the textbook swallow, and no structural signal separates it from
219
+ // `.catch(() => [])`. Where a fallback really is correct, the call site says so
220
+ // with a disable comment and a reason, which stays greppable.
221
+
200
222
  const defaultErrorReporters = ['app.reportError', 'app.handleError'];
201
223
 
202
224
  /**
@@ -363,6 +385,11 @@ const createSwallowedErrorRule = () => ({
363
385
  const reportHandler = (node, params, body) => {
364
386
  const names = params.flatMap((param) => collectPatternNames(param));
365
387
  if (names.length === 0) {
388
+ // Nothing was bound, so nothing can be routed. Still accepted when
389
+ // the handler translates the failure into a throw, because the
390
+ // failure keeps propagating rather than being continued past.
391
+ if (handlerThrows(body)) return;
392
+
366
393
  context.report({ node, messageId: 'missingParam' });
367
394
  return;
368
395
  }
@@ -373,6 +400,8 @@ const createSwallowedErrorRule = () => ({
373
400
  return;
374
401
  }
375
402
 
403
+ if (handlerThrows(body)) return;
404
+
376
405
  if (!handlerPreservesCaughtError(body, collectDerivedErrorNames(body, names), side, reporters)) {
377
406
  context.report({ node, messageId: 'unpreserved', data: { name: referencedName } });
378
407
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "proteum",
3
3
  "description": "LLM-first Opinionated Typescript Framework for web applications.",
4
- "version": "2.5.16",
4
+ "version": "2.5.17",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/proteum.git",
7
7
  "license": "MIT",
@@ -240,6 +240,46 @@ test('an error pushed into a result collection counts as preservation', () => {
240
240
  assert.equal(count(messages, swallowedRuleId), 0);
241
241
  });
242
242
 
243
+ test('translating a failure into a new throw counts, even without the original', () => {
244
+ const messages = lint(`
245
+ export const assertUrl = (raw) => {
246
+ try {
247
+ return new URL(raw);
248
+ } catch {
249
+ throw new Error('must be an absolute URL');
250
+ }
251
+ };
252
+ `);
253
+
254
+ assert.equal(count(messages, swallowedRuleId), 0);
255
+ });
256
+
257
+ test('preservation inside a loop body counts, because iteration is not a condition', () => {
258
+ const messages = lint(`
259
+ export const run = (batch) => {
260
+ load().catch((error) => {
261
+ for (const item of batch) {
262
+ item.reject(error);
263
+ }
264
+ });
265
+ };
266
+ `);
267
+
268
+ assert.equal(count(messages, swallowedRuleId), 0);
269
+ });
270
+
271
+ test('a returned fallback is still a swallow, however it is spelled', () => {
272
+ const nullFallback = lint(`
273
+ export const run = () => {
274
+ try { risky(); } catch { return null; }
275
+ };
276
+ `);
277
+ assert.equal(count(nullFallback, swallowedRuleId), 1);
278
+
279
+ const emptyList = lint(`export const run = (d) => dns.resolveMx(d).catch(() => []);`);
280
+ assert.equal(count(emptyList, swallowedRuleId), 1);
281
+ });
282
+
243
283
  test('a console-only catch is still reported', () => {
244
284
  const messages = lint(`
245
285
  export const run = async () => {