tackbox 0.1.0

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.
@@ -0,0 +1,47 @@
1
+ const { hasMarkerAbove, walk, isTier1Notify, argFlows, isTestFile } = require('./_shared')
2
+
3
+ // guarded: `call` sits under an additional condition strictly inside the catch
4
+ // body - an if-branch (consequent/alternate) or a switch case. A notify
5
+ // reachable without crossing such a construct is the unconditional sole
6
+ // handling of the whole catch.
7
+ function guarded(call, catchBody) {
8
+ for (let cur = call; cur && cur !== catchBody; cur = cur.parent) {
9
+ const p = cur.parent
10
+ if (!p) return false
11
+ if (p.type === 'IfStatement' && (cur === p.consequent || cur === p.alternate)) return true
12
+ if (p.type === 'SwitchCase') return true
13
+ }
14
+ return false
15
+ }
16
+
17
+ module.exports = {
18
+ meta: {
19
+ type: 'problem',
20
+ docs: {
21
+ description:
22
+ 'a notify terminating a catch must sit under an additional condition (if/switch), not handle the whole catch unconditionally - an unconditional notify routes every error to a toast and blinds telemetry (D006)',
23
+ },
24
+ messages: {
25
+ broad:
26
+ 'notify handles this catch unconditionally, routing every error to the user lane and blinding telemetry: put it under a condition and report the complement with reportError/reportWarn, or capture instead; a new // no-report: marker needs user approval',
27
+ },
28
+ schema: [],
29
+ },
30
+ create(context) {
31
+ if (isTestFile(context)) return {}
32
+ return {
33
+ CatchClause(node) {
34
+ const errName = node.param && node.param.type === 'Identifier' ? node.param.name : null
35
+ if (errName == null) return
36
+ const body = node.body
37
+ if (!body || body.type !== 'BlockStatement') return
38
+ if (hasMarkerAbove(context, node.parent, 'no-report')) return
39
+ walk(body, call => {
40
+ if (call.type !== 'CallExpression') return
41
+ if (!isTier1Notify(context, call) || !argFlows(call, errName)) return
42
+ if (!guarded(call, body)) context.report({ node: call, messageId: 'broad' })
43
+ })
44
+ },
45
+ }
46
+ },
47
+ }
@@ -0,0 +1,23 @@
1
+ const { isInDeclaredReporterBody } = require('./_shared')
2
+
3
+ module.exports = {
4
+ meta: {
5
+ type: 'problem',
6
+ docs: { description: 'ban console.error in favor of reportError' },
7
+ messages: { use: 'console.error is banned; use reportError/reportSynth instead' },
8
+ schema: [],
9
+ },
10
+ create(context) {
11
+ return {
12
+ CallExpression(node) {
13
+ const c = node.callee
14
+ if (!c || c.type !== 'MemberExpression') return
15
+ if (c.object && c.object.type === 'Identifier' && c.object.name === 'console' &&
16
+ c.property && c.property.type === 'Identifier' && c.property.name === 'error') {
17
+ if (isInDeclaredReporterBody(context, node)) return
18
+ context.report({ node, messageId: 'use' })
19
+ }
20
+ },
21
+ }
22
+ },
23
+ }
@@ -0,0 +1,22 @@
1
+ const { matchesTestModifier } = require('./_shared')
2
+
3
+ const BARE = new Set(['fit', 'fdescribe', 'ftest'])
4
+
5
+ module.exports = {
6
+ meta: {
7
+ type: 'problem',
8
+ docs: { description: 'focused tests disable the rest of the suite; no escape hatch, remove them' },
9
+ messages: {
10
+ focused: 'focused test disables the rest of the suite: remove the `.only` / `f`-prefix so every test runs',
11
+ },
12
+ schema: [],
13
+ },
14
+ create(context) {
15
+ return {
16
+ CallExpression(node) {
17
+ if (!matchesTestModifier(node.callee, BARE, n => n === 'only')) return
18
+ context.report({ node, messageId: 'focused' })
19
+ },
20
+ }
21
+ },
22
+ }
@@ -0,0 +1,150 @@
1
+ const {
2
+ hasMarkerAbove,
3
+ enclosingFn,
4
+ fnReturnsResultLike,
5
+ errObjectFlows,
6
+ objectCarriesErr,
7
+ walk,
8
+ } = require('./_shared')
9
+
10
+ // isJsonParseCall: syntactic `JSON.parse(...)`. v1 scope is JSON.parse only -
11
+ // syntactically unambiguous, so no name-trust is needed (plan F7c).
12
+ function isJsonParseCall(n) {
13
+ const c = n.callee
14
+ return (
15
+ !!c &&
16
+ c.type === 'MemberExpression' &&
17
+ !c.computed &&
18
+ c.object.type === 'Identifier' &&
19
+ c.object.name === 'JSON' &&
20
+ c.property.type === 'Identifier' &&
21
+ c.property.name === 'parse'
22
+ )
23
+ }
24
+
25
+ // tryBlockParses: the try block directly contains a JSON.parse call. walk stops
26
+ // at nested function boundaries, so a JSON.parse inside a callback (setTimeout,
27
+ // .map) - which this try does not guard in the same tick - does not trigger.
28
+ function tryBlockParses(block) {
29
+ let found = false
30
+ walk(block, n => {
31
+ if (n.type === 'CallExpression' && isJsonParseCall(n)) found = true
32
+ })
33
+ return found
34
+ }
35
+
36
+ // boundaryPropagates: `{ ok: false, cause|message: <err object> }` - a Result
37
+ // boundary that carries the caught error as a live object. Stricter than
38
+ // _shared.isBoundaryValue: F7c breaks on stringification (message: err.message),
39
+ // so the value predicate is object-flow, not a bare ref.
40
+ function boundaryPropagates(expr, errName) {
41
+ return objectCarriesErr(expr, errName, errObjectFlows)
42
+ }
43
+
44
+ // localCarrierRHS: the RHS of the last local assignment to `name` among stmts
45
+ // (a `const/let name = rhs` declarator or a `name = rhs` assignment). Resolves a
46
+ // two-step wrap (`const w = new Error(..., { cause: e }); throw w`), which F5
47
+ // credits by checking the branch-local assignment.
48
+ function localCarrierRHS(stmts, name) {
49
+ let rhs = null
50
+ for (const st of stmts) {
51
+ if (st.type === 'VariableDeclaration') {
52
+ for (const d of st.declarations) {
53
+ if (d.id.type === 'Identifier' && d.id.name === name && d.init) rhs = d.init
54
+ }
55
+ } else if (
56
+ st.type === 'ExpressionStatement' &&
57
+ st.expression.type === 'AssignmentExpression' &&
58
+ st.expression.operator === '=' &&
59
+ st.expression.left.type === 'Identifier' &&
60
+ st.expression.left.name === name
61
+ ) {
62
+ rhs = st.expression.right
63
+ }
64
+ }
65
+ return rhs
66
+ }
67
+
68
+ // containsExit: a return or throw anywhere in stmt (not descending into nested
69
+ // functions). Used to fail closed on opaque constructs (switch/loop/try) whose
70
+ // paths the analysis does not model.
71
+ function containsExit(stmt) {
72
+ let found = false
73
+ walk(stmt, n => {
74
+ if (n.type === 'ReturnStatement' || n.type === 'ThrowStatement') found = true
75
+ })
76
+ return found
77
+ }
78
+
79
+ // catchPropagates: every path out of the catch must terminate by re-throwing
80
+ // the caught error object or returning a Result boundary that carries it. A
81
+ // fallback value, a throw that drops or stringifies the error, or a
82
+ // report-and-continue is a swallow - no reporter credit (report+default =
83
+ // finding) and no fall-through credit. Mirror of Go ERC002 restricted to
84
+ // object-flow exits. States: 'ok' (path terminated chain-preservingly), 'bad'
85
+ // (a path swallows), 'fall' (control falls past to the next statement).
86
+ function catchPropagates(body, errName, allowBoundary) {
87
+ const topStmts = body.type === 'BlockStatement' ? body.body : []
88
+ // carrier resolves a bare local identifier to its assigned RHS (two-step
89
+ // wrap); other expressions pass through unchanged.
90
+ function carrier(expr) {
91
+ if (expr && expr.type === 'Identifier' && expr.name !== errName) {
92
+ const rhs = localCarrierRHS(topStmts, expr.name)
93
+ if (rhs) return rhs
94
+ }
95
+ return expr
96
+ }
97
+ function analyze(stmt) {
98
+ if (!stmt) return 'fall'
99
+ switch (stmt.type) {
100
+ case 'ThrowStatement':
101
+ return errObjectFlows(carrier(stmt.argument), errName) ? 'ok' : 'bad'
102
+ case 'ReturnStatement':
103
+ return allowBoundary && boundaryPropagates(carrier(stmt.argument), errName) ? 'ok' : 'bad'
104
+ case 'BlockStatement':
105
+ return analyzeList(stmt.body)
106
+ case 'IfStatement': {
107
+ const c = analyze(stmt.consequent)
108
+ if (c === 'bad') return 'bad'
109
+ const a = stmt.alternate ? analyze(stmt.alternate) : 'fall'
110
+ if (a === 'bad') return 'bad'
111
+ return c === 'ok' && a === 'ok' ? 'ok' : 'fall'
112
+ }
113
+ default:
114
+ return containsExit(stmt) ? 'bad' : 'fall'
115
+ }
116
+ }
117
+ function analyzeList(stmts) {
118
+ for (const stmt of stmts) {
119
+ const r = analyze(stmt)
120
+ if (r !== 'fall') return r
121
+ }
122
+ return 'fall'
123
+ }
124
+ return analyze(body) === 'ok'
125
+ }
126
+
127
+ module.exports = {
128
+ meta: {
129
+ type: 'problem',
130
+ docs: { description: 'a try containing JSON.parse must propagate the parse error on every catch path (throw the caught object, or a Result boundary carrying it). A fallback value, a stringified rethrow, or report-and-continue swallows it. Escape with a // parse-skip: marker.' },
131
+ messages: {
132
+ fallback: 'propagate the parse error: throw it or return a Result boundary carrying it',
133
+ },
134
+ schema: [],
135
+ },
136
+ create(context) {
137
+ return {
138
+ TryStatement(node) {
139
+ if (!tryBlockParses(node.block)) return
140
+ const handler = node.handler
141
+ if (!handler || !handler.body || handler.body.type !== 'BlockStatement') return
142
+ if (hasMarkerAbove(context, node, 'parse-skip')) return
143
+ const errName = handler.param && handler.param.type === 'Identifier' ? handler.param.name : null
144
+ const allowBoundary = fnReturnsResultLike(enclosingFn(node))
145
+ if (catchPropagates(handler.body, errName, allowBoundary)) return
146
+ context.report({ node: handler, messageId: 'fallback' })
147
+ },
148
+ }
149
+ },
150
+ }
@@ -0,0 +1,95 @@
1
+ const { hasMarkerAbove, matchesTestModifier, isStaticString, staticStringValue, TEST_ROOTS } = require('./_shared')
2
+
3
+ const SKIP_PROPS = new Set(['skip', 'todo', 'skipIf', 'fixme'])
4
+ const BARE = new Set(['xit', 'xdescribe', 'xtest'])
5
+ // Playwright's conditional forms carry (cond, 'reason'); skipIf/todo do not
6
+ // take a reason argument in any framework, so they stay marker-only.
7
+ const COND_REASON_PROPS = new Set(['skip', 'fixme'])
8
+
9
+ // outermostCall climbs to the whole `it.skipIf(cond)('n', fn)` statement so the
10
+ // marker sits above it; the skip property lives on the inner call, so reporting
11
+ // there and anchoring the marker higher keeps chained forms to one finding.
12
+ function outermostCall(node) {
13
+ let cur = node
14
+ while (cur.parent && cur.parent.type === 'CallExpression' && cur.parent.callee === cur) {
15
+ cur = cur.parent
16
+ }
17
+ return cur
18
+ }
19
+
20
+ function directProp(callee) {
21
+ if (callee.type === 'MemberExpression' && !callee.computed && callee.property.type === 'Identifier') {
22
+ return callee.property.name
23
+ }
24
+ return ''
25
+ }
26
+
27
+ function isFunctionExpr(n) {
28
+ return !!n && (n.type === 'FunctionExpression' || n.type === 'ArrowFunctionExpression')
29
+ }
30
+
31
+ // hasInCallReason: playwright `test.skip(cond, 'reason')` / `test.fixme(cond,
32
+ // 'reason')` - at least two args with a non-empty string reason last. A
33
+ // syntactic function there is the declaration form `(title, fn)` and earns
34
+ // nothing; a non-literal reason expression is trusted (mirrors ERC008).
35
+ function hasInCallReason(node) {
36
+ if (!COND_REASON_PROPS.has(directProp(node.callee))) return false
37
+ if (node.arguments.length < 2) return false
38
+ const last = node.arguments[node.arguments.length - 1]
39
+ if (isFunctionExpr(last)) return false
40
+ if (isStaticString(last)) return staticStringValue(last).trim().length > 0
41
+ return true
42
+ }
43
+
44
+ // skipValueVerdict classifies a node:test options `skip`/`todo` value:
45
+ // 'pass' (non-empty string reason, or trusted non-literal), 'flag'
46
+ // (reasonless: true, empty/whitespace string), null (falsy literal - the
47
+ // test is not skipped at all).
48
+ function skipValueVerdict(v) {
49
+ if (isStaticString(v)) return staticStringValue(v).trim().length > 0 ? 'pass' : 'flag'
50
+ if (v.type === 'Literal') return v.value ? 'flag' : null
51
+ return 'pass'
52
+ }
53
+
54
+ // optionsSkipVerdict scans the node:test options position (`test([name][,
55
+ // options][, fn])` - first or second argument) for a skip/todo property.
56
+ function optionsSkipVerdict(node) {
57
+ for (const arg of node.arguments.slice(0, 2)) {
58
+ if (!arg || arg.type !== 'ObjectExpression') continue
59
+ for (const p of arg.properties) {
60
+ if (p.type !== 'Property' || p.computed) continue
61
+ const key = p.key.type === 'Identifier' ? p.key.name : p.key.type === 'Literal' ? String(p.key.value) : ''
62
+ if (key !== 'skip' && key !== 'todo') continue
63
+ return skipValueVerdict(p.value)
64
+ }
65
+ }
66
+ return null
67
+ }
68
+
69
+ module.exports = {
70
+ meta: {
71
+ type: 'problem',
72
+ docs: { description: 'skipped tests silently drop coverage; unskip, state a framework-native reason in the call (node:test options skip/todo, playwright test.skip(cond, reason)), or justify with a // test-skip: <reason> marker above the statement' },
73
+ messages: {
74
+ skipped: 'skipped test silently drops coverage: unskip it or state a non-empty reason',
75
+ },
76
+ schema: [],
77
+ },
78
+ create(context) {
79
+ return {
80
+ CallExpression(node) {
81
+ if (matchesTestModifier(node.callee, BARE, n => SKIP_PROPS.has(n))) {
82
+ if (hasInCallReason(node)) return
83
+ if (hasMarkerAbove(context, outermostCall(node), 'test-skip')) return
84
+ context.report({ node, messageId: 'skipped' })
85
+ return
86
+ }
87
+ if (node.callee.type === 'Identifier' && TEST_ROOTS.has(node.callee.name)) {
88
+ if (optionsSkipVerdict(node) !== 'flag') return
89
+ if (hasMarkerAbove(context, outermostCall(node), 'test-skip')) return
90
+ context.report({ node, messageId: 'skipped' })
91
+ }
92
+ },
93
+ }
94
+ },
95
+ }
@@ -0,0 +1,52 @@
1
+ const { hasMarkerAbove, enclosingFn, someNode } = require('./_shared')
2
+
3
+ // isAllSettledCall: syntactic `Promise.allSettled(...)`. Matched by shape, not
4
+ // resolution - Promise is a global and allSettled is unambiguous.
5
+ function isAllSettledCall(node) {
6
+ const c = node.callee
7
+ return (
8
+ !!c &&
9
+ c.type === 'MemberExpression' &&
10
+ !c.computed &&
11
+ c.object.type === 'Identifier' &&
12
+ c.object.name === 'Promise' &&
13
+ c.property.type === 'Identifier' &&
14
+ c.property.name === 'allSettled'
15
+ )
16
+ }
17
+
18
+ // refsReason: root's subtree contains a `.reason` access (dot or computed
19
+ // string). Descends into nested functions - `.reason` is usually read inside a
20
+ // .forEach / .filter callback over the settled results, so the scan must not
21
+ // stop at function boundaries the way _shared.walk does.
22
+ function refsReason(root) {
23
+ return someNode(
24
+ root,
25
+ n =>
26
+ n.type === 'MemberExpression' &&
27
+ ((!n.computed && n.property.type === 'Identifier' && n.property.name === 'reason') ||
28
+ (n.computed && n.property.type === 'Literal' && n.property.value === 'reason')),
29
+ )
30
+ }
31
+
32
+ module.exports = {
33
+ meta: {
34
+ type: 'problem',
35
+ docs: { description: 'every Promise.allSettled call needs at least one `.reason` access in the enclosing function, else rejected outcomes are silently dropped - allSettled never rejects, so a discarded result is the quietest swallow. Escape with a // no-report: marker.' },
36
+ messages: {
37
+ swallow: 'read `.reason` on the rejected entries in the enclosing function',
38
+ },
39
+ schema: [],
40
+ },
41
+ create(context) {
42
+ const sc = context.sourceCode || context.getSourceCode()
43
+ return {
44
+ CallExpression(node) {
45
+ if (!isAllSettledCall(node)) return
46
+ if (hasMarkerAbove(context, node, 'no-report')) return
47
+ if (refsReason(enclosingFn(node) || sc.ast)) return
48
+ context.report({ node, messageId: 'swallow' })
49
+ },
50
+ }
51
+ },
52
+ }
@@ -0,0 +1,31 @@
1
+ const {
2
+ hasMarkerAbove,
3
+ enclosingFn,
4
+ fnReturnsResultLike,
5
+ makeHandledAnalysis,
6
+ } = require('./_shared')
7
+
8
+ module.exports = {
9
+ meta: {
10
+ type: 'problem',
11
+ docs: { description: 'every path out of a catch must throw, call a reporter, convert to a Result boundary (return { ok: false, cause: err } when the function returns Result/Attempt), or the try must carry a // no-report: marker. Boundary conversion is kin to the policy layer (specs/general/error-policies.md).' },
12
+ messages: {
13
+ swallow: 'every catch path must throw, call a reporter, or convert to a Result boundary',
14
+ },
15
+ schema: [],
16
+ },
17
+ create(context) {
18
+ return {
19
+ CatchClause(node) {
20
+ const body = node.body
21
+ if (!body || body.type !== 'BlockStatement') return
22
+ const tryStmt = node.parent
23
+ if (tryStmt && hasMarkerAbove(context, tryStmt, 'no-report')) return
24
+ const errName = node.param && node.param.type === 'Identifier' ? node.param.name : null
25
+ const allowBoundary = fnReturnsResultLike(enclosingFn(node))
26
+ if (makeHandledAnalysis({ context, errName, allowBoundary }).handled(body)) return
27
+ context.report({ node, messageId: 'swallow' })
28
+ },
29
+ }
30
+ },
31
+ }
@@ -0,0 +1,38 @@
1
+ const { hasMarkerAbove, makeHandledAnalysis } = require('./_shared')
2
+
3
+ // rejectionHandler returns the rejection-handler argument of a promise
4
+ // method: `.catch(onErr)` -> arg 0, `.then(onOk, onErr)` -> arg 1. A single-arg
5
+ // `.then(onOk)` propagates the rejection naturally, so there is nothing to
6
+ // check (null). Only `.catch` and `.then` are recognized.
7
+ function rejectionHandler(node) {
8
+ const callee = node.callee
9
+ if (!callee || callee.type !== 'MemberExpression') return null
10
+ if (!callee.property || callee.property.type !== 'Identifier') return null
11
+ if (callee.property.name === 'catch') return node.arguments[0] || null
12
+ if (callee.property.name === 'then') return node.arguments.length >= 2 ? node.arguments[1] : null
13
+ return null
14
+ }
15
+
16
+ module.exports = {
17
+ meta: {
18
+ type: 'problem',
19
+ docs: { description: 'every path out of a promise rejection handler (.catch(onErr) or the second arg of .then(onOk, onErr)) must throw or call a reporter, or carry a // no-report: marker. Result-boundary conversion is not accepted in promise handlers.' },
20
+ messages: {
21
+ swallow: 'every rejection-handler path must throw or call a reporter',
22
+ },
23
+ schema: [],
24
+ },
25
+ create(context) {
26
+ return {
27
+ CallExpression(node) {
28
+ const handler = rejectionHandler(node)
29
+ if (!handler) return
30
+ if (handler.type !== 'ArrowFunctionExpression' && handler.type !== 'FunctionExpression') return
31
+ if (hasMarkerAbove(context, node, 'no-report')) return
32
+ const errName = handler.params[0] && handler.params[0].type === 'Identifier' ? handler.params[0].name : null
33
+ if (makeHandledAnalysis({ context, errName, allowBoundary: false, returnIdentity: true }).handled(handler.body)) return
34
+ context.report({ node, messageId: 'swallow' })
35
+ },
36
+ }
37
+ },
38
+ }
@@ -0,0 +1,30 @@
1
+ const { blockHasThrow, blockHasReport, notifyCaptureConflict, isTestFile } = require('./_shared')
2
+
3
+ module.exports = {
4
+ meta: {
5
+ type: 'problem',
6
+ docs: { description: 'catch block must not both throw and call a reporter; nor both capture and notify on one path (D006 double-lane)' },
7
+ messages: {
8
+ both: 'catch block both throws and calls a reporter: pick one - upstream handler would re-capture',
9
+ doubleLane: 'catch path both captures and notifies: error/warn already reach the user lane, so the notify double-shows - drop the notify, or use only notify with no capture',
10
+ },
11
+ schema: [],
12
+ },
13
+ create(context) {
14
+ return {
15
+ CatchClause(node) {
16
+ const body = node.body
17
+ if (!body || body.type !== 'BlockStatement') return
18
+ const errName = node.param && node.param.type === 'Identifier' ? node.param.name : null
19
+ if (blockHasThrow(body) && blockHasReport(context, body, errName)) {
20
+ context.report({ node, messageId: 'both' })
21
+ }
22
+ // The double-lane arm is a new D006 rule and skips tests (parity with
23
+ // Go/Java); the `both` arm is pre-existing and keeps running in tests.
24
+ if (!isTestFile(context) && notifyCaptureConflict(context, body, errName)) {
25
+ context.report({ node, messageId: 'doubleLane' })
26
+ }
27
+ },
28
+ }
29
+ },
30
+ }
@@ -0,0 +1,26 @@
1
+ const { walk } = require('./_shared')
2
+
3
+ module.exports = {
4
+ meta: {
5
+ type: 'problem',
6
+ docs: { description: 'process.exit(...) inside catch masks the exception' },
7
+ messages: {
8
+ exit: 'process.exit(...) inside catch masks the exception: let it propagate',
9
+ },
10
+ schema: [],
11
+ },
12
+ create(context) {
13
+ return {
14
+ CatchClause(node) {
15
+ walk(node.body, n => {
16
+ if (n.type !== 'CallExpression') return
17
+ const callee = n.callee
18
+ if (!callee || callee.type !== 'MemberExpression') return
19
+ if (!callee.object || callee.object.type !== 'Identifier' || callee.object.name !== 'process') return
20
+ if (!callee.property || callee.property.type !== 'Identifier' || callee.property.name !== 'exit') return
21
+ context.report({ node: n, messageId: 'exit' })
22
+ })
23
+ },
24
+ }
25
+ },
26
+ }
@@ -0,0 +1,62 @@
1
+ const { walk } = require('./_shared')
2
+
3
+ // new AggregateError([...errors], msg) preserves the caught error in its
4
+ // errors array, so it does not also need { cause }.
5
+ function aggregateHoldsError(newExpr, errName) {
6
+ const callee = newExpr.callee
7
+ if (!callee || callee.type !== 'Identifier' || callee.name !== 'AggregateError') return false
8
+ const first = newExpr.arguments[0]
9
+ if (!first || first.type !== 'ArrayExpression') return false
10
+ return first.elements.some(el => {
11
+ if (!el) return false
12
+ if (el.type === 'Identifier') return el.name === errName
13
+ if (el.type === 'SpreadElement' && el.argument.type === 'Identifier') return el.argument.name === errName
14
+ return false
15
+ })
16
+ }
17
+
18
+ function optionsHasCause(newExpr, errName) {
19
+ for (const arg of newExpr.arguments) {
20
+ if (arg.type !== 'ObjectExpression') continue
21
+ for (const prop of arg.properties) {
22
+ if (prop.type !== 'Property' || prop.computed) continue
23
+ const key =
24
+ prop.key.type === 'Identifier'
25
+ ? prop.key.name
26
+ : prop.key.type === 'Literal'
27
+ ? prop.key.value
28
+ : null
29
+ if (key !== 'cause') continue
30
+ if (prop.value.type === 'Identifier' && prop.value.name === errName) return true
31
+ }
32
+ }
33
+ return false
34
+ }
35
+
36
+ module.exports = {
37
+ meta: {
38
+ type: 'problem',
39
+ docs: { description: 'throwing a new error in catch without { cause: <caught> } discards the original stack' },
40
+ messages: {
41
+ noCause: 'throw new Error in catch must pass { cause: <caught error> } to preserve the stack chain',
42
+ },
43
+ schema: [],
44
+ },
45
+ create(context) {
46
+ return {
47
+ CatchClause(node) {
48
+ const param = node.param
49
+ if (!param || param.type !== 'Identifier') return
50
+ const errName = param.name
51
+ walk(node.body, n => {
52
+ if (n.type !== 'ThrowStatement') return
53
+ const arg = n.argument
54
+ if (!arg || arg.type !== 'NewExpression') return
55
+ if (optionsHasCause(arg, errName)) return
56
+ if (aggregateHoldsError(arg, errName)) return
57
+ context.report({ node: n, messageId: 'noCause' })
58
+ })
59
+ },
60
+ }
61
+ },
62
+ }
@@ -0,0 +1,25 @@
1
+ module.exports = {
2
+ meta: {
3
+ type: 'problem',
4
+ docs: { description: 'catch that only re-throws the caught error is a no-op wrapper' },
5
+ messages: {
6
+ useless: 'catch only re-throws the caught error: remove the try/catch and let it propagate',
7
+ },
8
+ schema: [],
9
+ },
10
+ create(context) {
11
+ return {
12
+ CatchClause(node) {
13
+ const param = node.param
14
+ if (!param || param.type !== 'Identifier') return
15
+ const body = node.body && node.body.body
16
+ if (!body || body.length !== 1) return
17
+ const stmt = body[0]
18
+ if (stmt.type !== 'ThrowStatement') return
19
+ if (!stmt.argument || stmt.argument.type !== 'Identifier') return
20
+ if (stmt.argument.name !== param.name) return
21
+ context.report({ node, messageId: 'useless' })
22
+ },
23
+ }
24
+ },
25
+ }
@@ -0,0 +1,50 @@
1
+ const {
2
+ REPORTER_FULL, REPORTER_SYNTH,
3
+ tier1ReporterName, isTier1Notify, isStaticString, staticStringValue, DEDUP_KEY_RE, isTestFile,
4
+ } = require('./_shared')
5
+
6
+ module.exports = {
7
+ meta: {
8
+ type: 'problem',
9
+ docs: { description: 'dedupKey must be a static literal in `area.suffix[:identifier]` form' },
10
+ messages: {
11
+ notLiteral: '{{name}}: dedupKey must be a static string literal so the fingerprint is stable',
12
+ badFormat: '{{name}}: dedupKey must follow `area.suffix[:identifier]` format (got "{{value}}")',
13
+ },
14
+ schema: [],
15
+ },
16
+ create(context) {
17
+ if (isTestFile(context)) return {}
18
+ return {
19
+ CallExpression(node) {
20
+ let name = tier1ReporterName(context, node)
21
+
22
+ let keyIdx = -1
23
+ if (name) {
24
+ if (REPORTER_FULL.has(name)) keyIdx = 3
25
+ else if (REPORTER_SYNTH.has(name)) keyIdx = 2
26
+ else return
27
+ } else if (isTier1Notify(context, node)) {
28
+ // notify carries the full (msg, cause, tags, dedupKey) shape - its
29
+ // dedupKey is the same fingerprint/coalescing key (D008).
30
+ name = 'notify'
31
+ keyIdx = 3
32
+ } else {
33
+ return
34
+ }
35
+
36
+ const key = node.arguments[keyIdx]
37
+ if (!key) return // missing key is reported by valid-error-report
38
+
39
+ if (!isStaticString(key)) {
40
+ context.report({ node: key, messageId: 'notLiteral', data: { name } })
41
+ return
42
+ }
43
+ const v = staticStringValue(key)
44
+ if (!DEDUP_KEY_RE.test(v)) {
45
+ context.report({ node: key, messageId: 'badFormat', data: { name, value: v } })
46
+ }
47
+ },
48
+ }
49
+ },
50
+ }