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.
- package/LICENSE +21 -0
- package/README.md +552 -0
- package/bin/tackbox-eslint.js +147 -0
- package/bin/tackbox-mdlint.js +45 -0
- package/eslint.config.preset.js +21 -0
- package/js/README.md +209 -0
- package/js/eslint-plugin.js +48 -0
- package/js/markdownlint-rules/no-non-ascii.js +132 -0
- package/js/report.js +187 -0
- package/js/rules/_shared.js +809 -0
- package/js/rules/no-broad-notify.js +47 -0
- package/js/rules/no-console-error.js +23 -0
- package/js/rules/no-focused-test.js +22 -0
- package/js/rules/no-parse-fallback.js +150 -0
- package/js/rules/no-skipped-test.js +95 -0
- package/js/rules/no-swallow-allsettled.js +52 -0
- package/js/rules/no-swallow-catch.js +31 -0
- package/js/rules/no-swallow-promise-catch.js +38 -0
- package/js/rules/no-throw-and-report.js +30 -0
- package/js/rules/ts-exit-in-catch.js +26 -0
- package/js/rules/ts-rethrow-without-cause.js +62 -0
- package/js/rules/ts-useless-catch.js +25 -0
- package/js/rules/valid-dedup-key.js +50 -0
- package/js/rules/valid-error-report.js +99 -0
- package/js/tests/eslint-wrapper.test.js +53 -0
- package/js/tests/mdlint-wrapper.test.js +85 -0
- package/js/tests/no-non-ascii.test.js +124 -0
- package/js/tests/report.test.js +182 -0
- package/js/tests/reporters.test.js +223 -0
- package/js/tests/rules.test.js +650 -0
- package/js/tests/svelte.test.js +61 -0
- package/package.json +43 -0
|
@@ -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
|
+
}
|