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,809 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
|
|
4
|
+
// Canonical reporter names. A call counts as a reporter only when its
|
|
5
|
+
// callee resolves (scope analysis) to an import of `tackbox`/`tackbox/report`
|
|
6
|
+
// carrying one of these names (tier-1), or to a function declared in
|
|
7
|
+
// `.tackbox-reporters` (tier-2). A bare identifier that merely shares the
|
|
8
|
+
// name is not trusted - name-only matching is dead.
|
|
9
|
+
const REPORTER_NAMES = new Set([
|
|
10
|
+
'reportError',
|
|
11
|
+
'reportSynth',
|
|
12
|
+
'reportSynthError',
|
|
13
|
+
'reportApiError',
|
|
14
|
+
'reportWarn',
|
|
15
|
+
'reportQuiet',
|
|
16
|
+
'reportLayerError',
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
// Reporters that require (msg, cause, tags, dedupKey) - 4 args.
|
|
20
|
+
const REPORTER_FULL = new Set([
|
|
21
|
+
'reportError',
|
|
22
|
+
'reportWarn',
|
|
23
|
+
'reportQuiet',
|
|
24
|
+
'reportApiError',
|
|
25
|
+
'reportLayerError',
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
// Reporters that omit cause: (msg, tags, dedupKey) - 3 args.
|
|
29
|
+
const REPORTER_SYNTH = new Set(['reportSynth', 'reportSynthError'])
|
|
30
|
+
|
|
31
|
+
// Modules whose imports are trusted as reporter origins (tier-1).
|
|
32
|
+
const TACKBOX_MODULES = new Set(['tackbox', 'tackbox/report'])
|
|
33
|
+
|
|
34
|
+
const DEDUP_KEY_RE = /^[a-z][a-z0-9_-]*\.[a-z][a-z0-9_-]*(:[a-zA-Z0-9_.-]+)?$/
|
|
35
|
+
|
|
36
|
+
// MIN_REASON is the floor on a suppression marker's reason length after
|
|
37
|
+
// trimming (D009): non-empty was too cheap (`ok` / `todo` passed).
|
|
38
|
+
const MIN_REASON = 10
|
|
39
|
+
|
|
40
|
+
function calleeName(node) {
|
|
41
|
+
if (!node) return ''
|
|
42
|
+
if (node.type === 'Identifier') return node.name
|
|
43
|
+
if (node.type === 'MemberExpression' && node.property) {
|
|
44
|
+
if (node.property.type === 'Identifier') return node.property.name
|
|
45
|
+
}
|
|
46
|
+
return ''
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- tier-1: import-origin resolution ------------------------------------
|
|
50
|
+
|
|
51
|
+
function resolveVar(context, idNode) {
|
|
52
|
+
const sc = context.sourceCode || context.getSourceCode()
|
|
53
|
+
let scope = sc.getScope(idNode)
|
|
54
|
+
while (scope) {
|
|
55
|
+
for (const ref of scope.references) {
|
|
56
|
+
if (ref.identifier === idNode) return ref.resolved || null
|
|
57
|
+
}
|
|
58
|
+
scope = scope.upper
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// importInfo classifies the binding a variable came from:
|
|
64
|
+
// {source, kind: 'named'|'default'|'namespace', imported?}. Covers ESM
|
|
65
|
+
// imports and CJS `require`. Returns null for locals / non-tackbox origins.
|
|
66
|
+
function importInfo(variable) {
|
|
67
|
+
if (!variable || !variable.defs || variable.defs.length === 0) return null
|
|
68
|
+
const def = variable.defs[0]
|
|
69
|
+
if (def.type === 'ImportBinding') {
|
|
70
|
+
const source = def.parent && def.parent.source && def.parent.source.value
|
|
71
|
+
const node = def.node
|
|
72
|
+
if (node.type === 'ImportNamespaceSpecifier') return { source, kind: 'namespace' }
|
|
73
|
+
if (node.type === 'ImportDefaultSpecifier') return { source, kind: 'default' }
|
|
74
|
+
const imported = node.imported
|
|
75
|
+
? node.imported.name || node.imported.value
|
|
76
|
+
: node.local.name
|
|
77
|
+
return { source, kind: 'named', imported }
|
|
78
|
+
}
|
|
79
|
+
if (def.type === 'Variable' && def.node && def.node.type === 'VariableDeclarator') {
|
|
80
|
+
return requireInfo(def.node, variable.name)
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function requireInfo(declarator, localName) {
|
|
86
|
+
const init = declarator.init
|
|
87
|
+
if (!init || init.type !== 'CallExpression') return null
|
|
88
|
+
if (init.callee.type !== 'Identifier' || init.callee.name !== 'require') return null
|
|
89
|
+
const arg = init.arguments[0]
|
|
90
|
+
if (!arg || arg.type !== 'Literal' || typeof arg.value !== 'string') return null
|
|
91
|
+
const source = arg.value
|
|
92
|
+
if (declarator.id.type === 'Identifier') return { source, kind: 'namespace' }
|
|
93
|
+
if (declarator.id.type === 'ObjectPattern') {
|
|
94
|
+
for (const p of declarator.id.properties) {
|
|
95
|
+
if (
|
|
96
|
+
p.type === 'Property' &&
|
|
97
|
+
p.value.type === 'Identifier' &&
|
|
98
|
+
p.value.name === localName &&
|
|
99
|
+
p.key
|
|
100
|
+
) {
|
|
101
|
+
return { source, kind: 'named', imported: p.key.name || p.key.value }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// tier1ImportedName returns the tackbox-module imported name `call`'s callee
|
|
109
|
+
// resolves to - a named import used directly, or a namespace/default member -
|
|
110
|
+
// else null. Origin-gated (mirrors the Go package gate); the shared core of
|
|
111
|
+
// reporter and notify recognition.
|
|
112
|
+
function tier1ImportedName(context, call) {
|
|
113
|
+
const callee = call.callee
|
|
114
|
+
if (!callee) return null
|
|
115
|
+
if (callee.type === 'Identifier') {
|
|
116
|
+
const info = importInfo(resolveVar(context, callee))
|
|
117
|
+
if (!info || !TACKBOX_MODULES.has(info.source) || info.kind !== 'named') return null
|
|
118
|
+
return info.imported
|
|
119
|
+
}
|
|
120
|
+
if (
|
|
121
|
+
callee.type === 'MemberExpression' &&
|
|
122
|
+
!callee.computed &&
|
|
123
|
+
callee.object.type === 'Identifier' &&
|
|
124
|
+
callee.property.type === 'Identifier'
|
|
125
|
+
) {
|
|
126
|
+
const info = importInfo(resolveVar(context, callee.object))
|
|
127
|
+
if (!info || !TACKBOX_MODULES.has(info.source)) return null
|
|
128
|
+
if (info.kind !== 'namespace' && info.kind !== 'default') return null
|
|
129
|
+
return callee.property.name
|
|
130
|
+
}
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// tier1ReporterName returns the reporter name when `call`'s callee resolves
|
|
135
|
+
// to a REPORTER_NAMES import of a tackbox module, else null.
|
|
136
|
+
function tier1ReporterName(context, call) {
|
|
137
|
+
const name = tier1ImportedName(context, call)
|
|
138
|
+
return name !== null && REPORTER_NAMES.has(name) ? name : null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// isTier1Notify reports whether `call` resolves to the tackbox `notify` verb.
|
|
142
|
+
// Origin-gated like a reporter but deliberately NOT in REPORTER_NAMES: notify
|
|
143
|
+
// never credits a swallow as a capture and never counts as a reporter for
|
|
144
|
+
// no-throw-and-report. no-broad-notify gates it; valid-error-report and
|
|
145
|
+
// valid-dedup-key validate its msg and dedupKey.
|
|
146
|
+
function isTier1Notify(context, call) {
|
|
147
|
+
return tier1ImportedName(context, call) === 'notify'
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isTier1ReporterCall(context, call) {
|
|
151
|
+
return tier1ReporterName(context, call) !== null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- tier-2: .tackbox-reporters declarations -----------------------------
|
|
155
|
+
|
|
156
|
+
function declaredReporters(context) {
|
|
157
|
+
const s = context.settings && context.settings.tackbox && context.settings.tackbox.reporters
|
|
158
|
+
return Array.isArray(s) ? s : []
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function relFile(context) {
|
|
162
|
+
const fn = context.filename || (context.getFilename && context.getFilename()) || ''
|
|
163
|
+
const cwd = context.cwd || process.cwd()
|
|
164
|
+
return path.isAbsolute(fn) ? path.relative(cwd, fn) : fn
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Module extensions, compound first: a specifier that omits the extension must
|
|
168
|
+
// strip to the same base as the declaration. `.svelte.ts` / `.svelte.js` are
|
|
169
|
+
// Svelte rune modules that keep the double extension, so a specifier of `x`,
|
|
170
|
+
// `x.svelte`, or `x.svelte.ts` all reduce to `x` and match a `x.svelte.ts`
|
|
171
|
+
// declaration. Order matters: the compound forms are tried before the simple
|
|
172
|
+
// ones (`.svelte.ts` before `.ts` and before `.svelte`).
|
|
173
|
+
const MODULE_EXTS = [
|
|
174
|
+
'.svelte.ts', '.svelte.js',
|
|
175
|
+
'.ts', '.tsx', '.mts', '.cts',
|
|
176
|
+
'.js', '.jsx', '.mjs', '.cjs',
|
|
177
|
+
'.svelte',
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
function stripModuleExt(p) {
|
|
181
|
+
const base = p.slice(p.lastIndexOf('/') + 1)
|
|
182
|
+
for (const ext of MODULE_EXTS) {
|
|
183
|
+
if (base.length > ext.length && base.endsWith(ext)) return p.slice(0, p.length - ext.length)
|
|
184
|
+
}
|
|
185
|
+
return p
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function matchesDecl(decls, file, name) {
|
|
189
|
+
for (const d of decls) {
|
|
190
|
+
const hash = d.lastIndexOf('#')
|
|
191
|
+
if (hash < 0) continue
|
|
192
|
+
if (d.slice(hash + 1) !== name) continue
|
|
193
|
+
const dfile = d.slice(0, hash)
|
|
194
|
+
if (dfile === file || stripModuleExt(dfile) === stripModuleExt(file)) return true
|
|
195
|
+
}
|
|
196
|
+
return false
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function absFile(context) {
|
|
200
|
+
const fn = context.filename || (context.getFilename && context.getFilename()) || ''
|
|
201
|
+
if (path.isAbsolute(fn)) return fn
|
|
202
|
+
return path.resolve(context.cwd || process.cwd(), fn)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// isTestFile: the linted file is a test - a `*.test.*` / `*.spec.*` basename, or
|
|
206
|
+
// a `__tests__` / `tests` path segment. The new reporter-arg / notify-gate rules
|
|
207
|
+
// skip tests (parity with Go _test.go and Java src/test); the swallow and
|
|
208
|
+
// test-skip rules do not - they must keep running in tests.
|
|
209
|
+
function isTestFile(context) {
|
|
210
|
+
const fn = ((context.filename || (context.getFilename && context.getFilename()) || '')).replace(/\\/g, '/')
|
|
211
|
+
const base = fn.slice(fn.lastIndexOf('/') + 1)
|
|
212
|
+
return /\.(test|spec)\./.test(base) || /(^|\/)(__tests__|tests)\//.test(fn)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const SVELTE_CONFIG_NAMES = ['svelte.config.js', 'svelte.config.ts', 'svelte.config.mjs', 'svelte.config.cjs']
|
|
216
|
+
|
|
217
|
+
// resolveAlias maps a SvelteKit `$lib` specifier to a repo-relative path.
|
|
218
|
+
// Deterministic and CI-safe: `$lib` -> `<nearest ancestor of the importing file
|
|
219
|
+
// holding svelte.config.*>/src/lib`, the committed SvelteKit convention. It
|
|
220
|
+
// never reads `.svelte-kit/tsconfig.json` (generated, gitignored, absent on a
|
|
221
|
+
// fresh clone). Returns null for any other specifier or when no svelte.config
|
|
222
|
+
// is found - the caller then leaves the import unresolved.
|
|
223
|
+
function resolveAlias(context, source, absImporter) {
|
|
224
|
+
if (source !== '$lib' && !source.startsWith('$lib/')) return null
|
|
225
|
+
const rest = source === '$lib' ? '' : source.slice('$lib/'.length)
|
|
226
|
+
let dir = path.dirname(absImporter)
|
|
227
|
+
let root = null
|
|
228
|
+
for (;;) {
|
|
229
|
+
if (SVELTE_CONFIG_NAMES.some(n => fs.existsSync(path.join(dir, n)))) {
|
|
230
|
+
root = dir
|
|
231
|
+
break
|
|
232
|
+
}
|
|
233
|
+
const up = path.dirname(dir)
|
|
234
|
+
if (up === dir) break
|
|
235
|
+
dir = up
|
|
236
|
+
}
|
|
237
|
+
if (root === null) return null
|
|
238
|
+
return path.relative(context.cwd || process.cwd(), path.join(root, 'src', 'lib', rest))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// resolveDeclTarget resolves an Identifier callee to the {file, name} of its
|
|
242
|
+
// definition: a local top-level def in this file, a single-hop relative import,
|
|
243
|
+
// or a `$lib` SvelteKit alias import. Barrel re-exports are not followed (plan:
|
|
244
|
+
// direct import or wrapper declaration only).
|
|
245
|
+
function resolveDeclTarget(context, idNode) {
|
|
246
|
+
const variable = resolveVar(context, idNode)
|
|
247
|
+
if (!variable || !variable.defs || variable.defs.length === 0) return null
|
|
248
|
+
const info = importInfo(variable)
|
|
249
|
+
if (info) {
|
|
250
|
+
const source = info.source
|
|
251
|
+
if (typeof source !== 'string') return null
|
|
252
|
+
const importedName = info.kind === 'named' ? info.imported : idNode.name
|
|
253
|
+
let resolved
|
|
254
|
+
if (source.startsWith('.')) {
|
|
255
|
+
resolved = path.normalize(path.join(path.dirname(relFile(context)), source))
|
|
256
|
+
} else {
|
|
257
|
+
resolved = resolveAlias(context, source, absFile(context))
|
|
258
|
+
if (resolved === null) return null
|
|
259
|
+
}
|
|
260
|
+
return { file: resolved, name: importedName }
|
|
261
|
+
}
|
|
262
|
+
const def = variable.defs[0]
|
|
263
|
+
if (def.type === 'FunctionName' || def.type === 'Variable') {
|
|
264
|
+
return { file: relFile(context), name: variable.name }
|
|
265
|
+
}
|
|
266
|
+
return null
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// argFlows: the caught error identifier appears somewhere in the call's
|
|
270
|
+
// arguments (catch param, promise-catch handler param, or recover value).
|
|
271
|
+
function argFlows(call, errName) {
|
|
272
|
+
if (errName == null) return false
|
|
273
|
+
let found = false
|
|
274
|
+
for (const arg of call.arguments) {
|
|
275
|
+
walk(arg, n => {
|
|
276
|
+
if (n.type === 'Identifier' && n.name === errName) found = true
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
return found
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// resolvesToDeclaredReporter: `call`'s callee resolves to a function declared in
|
|
283
|
+
// `.tackbox-reporters` for its origin file. Pure origin recognition - the single
|
|
284
|
+
// declared-reporter resolver. no-swallow layers an argument-flow gate on top
|
|
285
|
+
// (the caught err must reach the call).
|
|
286
|
+
function resolvesToDeclaredReporter(context, call) {
|
|
287
|
+
const decls = declaredReporters(context)
|
|
288
|
+
if (decls.length === 0) return false
|
|
289
|
+
const callee = call.callee
|
|
290
|
+
if (!callee || callee.type !== 'Identifier') return false
|
|
291
|
+
const target = resolveDeclTarget(context, callee)
|
|
292
|
+
return !!target && matchesDecl(decls, target.file, target.name)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function isDeclaredReporterCall(context, call, errName) {
|
|
296
|
+
return resolvesToDeclaredReporter(context, call) && argFlows(call, errName)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// isInDeclaredReporterBody: `node` is lexically inside a function declared in
|
|
300
|
+
// `.tackbox-reporters` for this file - no-console-error does not apply there
|
|
301
|
+
// (the declared function is itself the reporter).
|
|
302
|
+
function isInDeclaredReporterBody(context, node) {
|
|
303
|
+
const decls = declaredReporters(context)
|
|
304
|
+
if (decls.length === 0) return false
|
|
305
|
+
const file = relFile(context)
|
|
306
|
+
let cur = node.parent
|
|
307
|
+
while (cur) {
|
|
308
|
+
let fname = null
|
|
309
|
+
if (cur.type === 'FunctionDeclaration' && cur.id) {
|
|
310
|
+
fname = cur.id.name
|
|
311
|
+
} else if (
|
|
312
|
+
(cur.type === 'FunctionExpression' || cur.type === 'ArrowFunctionExpression') &&
|
|
313
|
+
cur.parent &&
|
|
314
|
+
cur.parent.type === 'VariableDeclarator' &&
|
|
315
|
+
cur.parent.id.type === 'Identifier'
|
|
316
|
+
) {
|
|
317
|
+
fname = cur.parent.id.name
|
|
318
|
+
}
|
|
319
|
+
if (fname && matchesDecl(decls, file, fname)) return true
|
|
320
|
+
cur = cur.parent
|
|
321
|
+
}
|
|
322
|
+
return false
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// --- recognition + block scanning ----------------------------------------
|
|
326
|
+
|
|
327
|
+
function isReporterCall(context, call, errName) {
|
|
328
|
+
return isTier1ReporterCall(context, call) || isDeclaredReporterCall(context, call, errName)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function isThrowStatement(stmt) {
|
|
332
|
+
return stmt && stmt.type === 'ThrowStatement'
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function isStaticString(node) {
|
|
336
|
+
if (!node) return false
|
|
337
|
+
if (node.type === 'Literal' && typeof node.value === 'string') return true
|
|
338
|
+
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) return true
|
|
339
|
+
return false
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function staticStringValue(node) {
|
|
343
|
+
if (node.type === 'Literal') return String(node.value)
|
|
344
|
+
if (node.type === 'TemplateLiteral') return node.quasis.map(q => q.value.cooked).join('')
|
|
345
|
+
return ''
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function walk(node, fn) {
|
|
349
|
+
if (!node || typeof node !== 'object') return
|
|
350
|
+
if (Array.isArray(node)) {
|
|
351
|
+
for (const c of node) walk(c, fn)
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
fn(node)
|
|
355
|
+
for (const key of Object.keys(node)) {
|
|
356
|
+
if (key === 'parent' || key === 'loc' || key === 'range') continue
|
|
357
|
+
const child = node[key]
|
|
358
|
+
if (!child || typeof child !== 'object') continue
|
|
359
|
+
if (
|
|
360
|
+
(node.type === 'FunctionExpression' ||
|
|
361
|
+
node.type === 'ArrowFunctionExpression' ||
|
|
362
|
+
node.type === 'FunctionDeclaration') &&
|
|
363
|
+
key === 'body'
|
|
364
|
+
) {
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
367
|
+
walk(child, fn)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function blockHasThrow(block) {
|
|
372
|
+
let found = false
|
|
373
|
+
walk(block, n => {
|
|
374
|
+
if (isThrowStatement(n)) found = true
|
|
375
|
+
})
|
|
376
|
+
return found
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function blockHasReport(context, block, errName) {
|
|
380
|
+
let found = false
|
|
381
|
+
walk(block, n => {
|
|
382
|
+
if (n.type === 'CallExpression' && isReporterCall(context, n, errName)) found = true
|
|
383
|
+
})
|
|
384
|
+
return found
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// hasMarkerAbove returns true when the comment block directly above node
|
|
388
|
+
// carries `// <prefix>: <reason>` (reason at least MIN_REASON chars, D009) on
|
|
389
|
+
// any of its lines - not only the line immediately above, so a long reason can
|
|
390
|
+
// be followed by human context. A blank line breaks the block (adjacency
|
|
391
|
+
// required).
|
|
392
|
+
function hasMarkerAbove(context, node, prefix) {
|
|
393
|
+
if (!node || !node.loc) return false
|
|
394
|
+
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
395
|
+
const byEndLine = new Map()
|
|
396
|
+
for (const c of sourceCode.getAllComments()) {
|
|
397
|
+
if (c.type === 'Line') byEndLine.set(c.loc.end.line, c)
|
|
398
|
+
}
|
|
399
|
+
for (let line = node.loc.start.line - 1; byEndLine.has(line); line--) {
|
|
400
|
+
const text = byEndLine.get(line).value.trim()
|
|
401
|
+
if (!text.startsWith(prefix + ':')) continue
|
|
402
|
+
const reason = text.slice(prefix.length + 1).trim()
|
|
403
|
+
if (reason.length >= MIN_REASON) return true
|
|
404
|
+
}
|
|
405
|
+
return false
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// --- F2b: path-sensitive no-swallow analysis -----------------------------
|
|
409
|
+
// One coherent path analysis for all three legal catch exits: throw and a
|
|
410
|
+
// Result-boundary return terminate a path; a recognized reporter call is a
|
|
411
|
+
// sticky event (statements after it on the path are fine). A path reaching the
|
|
412
|
+
// end of the handler without a terminator or event swallows. Ported from gmux
|
|
413
|
+
// makeHandledAnalysis; reporter recognition stays tackbox origin-gating.
|
|
414
|
+
// Result-boundary is kin to the policy layer (specs/general/error-policies.md):
|
|
415
|
+
// annotation-based (no type program) - only a syntactic Result / Attempt /
|
|
416
|
+
// Promise<Result|Attempt> return type earns the boundary credit.
|
|
417
|
+
|
|
418
|
+
function isResultLikeType(t) {
|
|
419
|
+
if (!t || t.type !== 'TSTypeReference' || !t.typeName || t.typeName.type !== 'Identifier') return false
|
|
420
|
+
const name = t.typeName.name
|
|
421
|
+
if (name === 'Result' || name === 'Attempt') return true
|
|
422
|
+
if (name === 'Promise') {
|
|
423
|
+
const args = (t.typeArguments && t.typeArguments.params) || (t.typeParameters && t.typeParameters.params)
|
|
424
|
+
return Array.isArray(args) && args.length >= 1 && isResultLikeType(args[0])
|
|
425
|
+
}
|
|
426
|
+
return false
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function enclosingFn(node) {
|
|
430
|
+
let cur = node && node.parent
|
|
431
|
+
while (cur) {
|
|
432
|
+
if (
|
|
433
|
+
cur.type === 'FunctionDeclaration' ||
|
|
434
|
+
cur.type === 'FunctionExpression' ||
|
|
435
|
+
cur.type === 'ArrowFunctionExpression'
|
|
436
|
+
) return cur
|
|
437
|
+
cur = cur.parent
|
|
438
|
+
}
|
|
439
|
+
return null
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function fnReturnsResultLike(fn) {
|
|
443
|
+
return !!fn && !!fn.returnType && isResultLikeType(fn.returnType.typeAnnotation)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function exprRefsIdent(node, name) {
|
|
447
|
+
if (name == null) return false
|
|
448
|
+
let found = false
|
|
449
|
+
walk(node, n => {
|
|
450
|
+
if (n.type === 'Identifier' && n.name === name) found = true
|
|
451
|
+
})
|
|
452
|
+
return found
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// stringifyingNode: a construct that coerces its content to a string, so an
|
|
456
|
+
// err inside it is a stringified occurrence, not object flow. The JS analog of
|
|
457
|
+
// the Go astutil.stringifies set: a `.message`/`.stack` property access, a
|
|
458
|
+
// `String(...)` conversion, an `x.toString()` call, a template literal, or `+`
|
|
459
|
+
// concatenation.
|
|
460
|
+
function stringifyingNode(n) {
|
|
461
|
+
if (
|
|
462
|
+
n.type === 'MemberExpression' &&
|
|
463
|
+
!n.computed &&
|
|
464
|
+
n.property.type === 'Identifier' &&
|
|
465
|
+
(n.property.name === 'message' || n.property.name === 'stack')
|
|
466
|
+
) return true
|
|
467
|
+
if (n.type === 'CallExpression' && n.callee.type === 'Identifier' && n.callee.name === 'String') return true
|
|
468
|
+
if (
|
|
469
|
+
n.type === 'CallExpression' &&
|
|
470
|
+
n.callee.type === 'MemberExpression' &&
|
|
471
|
+
!n.callee.computed &&
|
|
472
|
+
n.callee.property.type === 'Identifier' &&
|
|
473
|
+
n.callee.property.name === 'toString'
|
|
474
|
+
) return true
|
|
475
|
+
if (n.type === 'TemplateLiteral') return true
|
|
476
|
+
if (n.type === 'BinaryExpression' && n.operator === '+') return true
|
|
477
|
+
return false
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// errObjectFlows reports whether errName reaches root as a live object: found as
|
|
481
|
+
// a bare identifier outside any stringifying construct. Subtrees that stringify
|
|
482
|
+
// their content are pruned - an err inside them is a stringified occurrence, not
|
|
483
|
+
// object flow. The JS analog of Go astutil.errObjectFlows (F5 object-flow: a
|
|
484
|
+
// composite literal, a constructor argument, or a bare rethrow propagates; the
|
|
485
|
+
// chain breaks only when every occurrence of err passes through a string).
|
|
486
|
+
// someNode: explicit-stack DFS over an ESTree subtree, returning true as soon
|
|
487
|
+
// as match(node) holds. prune(node) (optional) skips a node and its subtree.
|
|
488
|
+
// Unlike walk() this descends into nested function bodies (object-flow must not
|
|
489
|
+
// stop at a boundary), so it takes predicates rather than pruning structurally.
|
|
490
|
+
function someNode(root, match, prune) {
|
|
491
|
+
const stack = [root]
|
|
492
|
+
while (stack.length) {
|
|
493
|
+
const n = stack.pop()
|
|
494
|
+
if (!n || typeof n !== 'object') continue
|
|
495
|
+
if (Array.isArray(n)) {
|
|
496
|
+
for (const c of n) stack.push(c)
|
|
497
|
+
continue
|
|
498
|
+
}
|
|
499
|
+
if (prune && prune(n)) continue
|
|
500
|
+
if (match(n)) return true
|
|
501
|
+
for (const key of Object.keys(n)) {
|
|
502
|
+
if (key === 'parent' || key === 'loc' || key === 'range') continue
|
|
503
|
+
const child = n[key]
|
|
504
|
+
if (child && typeof child === 'object') stack.push(child)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return false
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function errObjectFlows(root, errName) {
|
|
511
|
+
if (errName == null) return false
|
|
512
|
+
return someNode(root, n => n.type === 'Identifier' && n.name === errName, stringifyingNode)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// objectCarriesErr: `{ ok: false, cause|message: <valueCarries(v, err)> }`.
|
|
516
|
+
// A bare { ok: false } drops the caught error and does not qualify. valueCarries
|
|
517
|
+
// decides whether a property value carries err (a plain ref, or object flow).
|
|
518
|
+
function objectCarriesErr(expr, errName, valueCarries) {
|
|
519
|
+
if (!errName || !expr || expr.type !== 'ObjectExpression') return false
|
|
520
|
+
const okProp = expr.properties.find(
|
|
521
|
+
p => p.type === 'Property' && p.key && p.key.type === 'Identifier' && p.key.name === 'ok',
|
|
522
|
+
)
|
|
523
|
+
if (!okProp || !okProp.value || okProp.value.type !== 'Literal' || okProp.value.value !== false) return false
|
|
524
|
+
return expr.properties.some(
|
|
525
|
+
p =>
|
|
526
|
+
p.type === 'Property' &&
|
|
527
|
+
p.key &&
|
|
528
|
+
p.key.type === 'Identifier' &&
|
|
529
|
+
(p.key.name === 'cause' || p.key.name === 'message') &&
|
|
530
|
+
valueCarries(p.value, errName),
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function isBoundaryValue(expr, errName) {
|
|
535
|
+
return objectCarriesErr(expr, errName, exprRefsIdent)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function containsReturn(node) {
|
|
539
|
+
let found = false
|
|
540
|
+
walk(node, n => {
|
|
541
|
+
if (n.type === 'ReturnStatement') found = true
|
|
542
|
+
})
|
|
543
|
+
return found
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// isReporterExpr: a (possibly awaited / void-wrapped) recognized reporter call,
|
|
547
|
+
// or a tackbox notify carrying the caught error - a notify routes the error to
|
|
548
|
+
// the user lane, terminating that path for the swallow rules (D006), so a
|
|
549
|
+
// notified path does not read as a swallow. no-broad-notify owns whether the
|
|
550
|
+
// notify is narrow enough; notify is never a capture (isReporterCall excludes
|
|
551
|
+
// it), so no-throw-and-report is unaffected. Unwrapping preserves tackbox's
|
|
552
|
+
// existing recognition of `await reportError(e)`.
|
|
553
|
+
function isReporterExpr(context, expr, errName) {
|
|
554
|
+
let e = expr
|
|
555
|
+
while (e && (e.type === 'AwaitExpression' || (e.type === 'UnaryExpression' && e.operator === 'void'))) {
|
|
556
|
+
e = e.argument
|
|
557
|
+
}
|
|
558
|
+
if (!e || e.type !== 'CallExpression') return false
|
|
559
|
+
return isReporterCall(context, e, errName) || (isTier1Notify(context, e) && argFlows(e, errName))
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// isExecutorRejectCall: a call to the enclosing `new Promise((resolve,
|
|
563
|
+
// reject) => ...)` executor's second parameter carrying the err object - the
|
|
564
|
+
// promise's own rethrow channel. Resolution is structural (the scope binding
|
|
565
|
+
// must be that exact parameter); a free-standing function named `reject`
|
|
566
|
+
// earns nothing, and a stringified argument breaks the chain.
|
|
567
|
+
function isExecutorRejectCall(context, expr, errName) {
|
|
568
|
+
let e = expr
|
|
569
|
+
while (e && e.type === 'AwaitExpression') e = e.argument
|
|
570
|
+
if (!e || e.type !== 'CallExpression' || e.callee.type !== 'Identifier') return false
|
|
571
|
+
if (!e.arguments.length || !errObjectFlows(e.arguments, errName)) return false
|
|
572
|
+
const sc = context.sourceCode || context.getSourceCode()
|
|
573
|
+
let variable = null
|
|
574
|
+
for (let s = sc.getScope(e.callee); s && !variable; s = s.upper) {
|
|
575
|
+
variable = s.variables.find(v => v.name === e.callee.name) || null
|
|
576
|
+
}
|
|
577
|
+
if (!variable || variable.defs.length !== 1) return false
|
|
578
|
+
const def = variable.defs[0]
|
|
579
|
+
if (def.type !== 'Parameter') return false
|
|
580
|
+
const fn = def.node
|
|
581
|
+
if (!fn.params || fn.params[1] !== def.name) return false
|
|
582
|
+
const parent = fn.parent
|
|
583
|
+
return (
|
|
584
|
+
!!parent &&
|
|
585
|
+
parent.type === 'NewExpression' &&
|
|
586
|
+
parent.callee.type === 'Identifier' &&
|
|
587
|
+
parent.callee.name === 'Promise' &&
|
|
588
|
+
parent.arguments[0] === fn
|
|
589
|
+
)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// isBareErrReturn: the returned expression IS the caught error object (an
|
|
593
|
+
// await-unwrapped bare identifier). The settled value being the error itself
|
|
594
|
+
// is the recognized rejection-to-value idiom; any wrapper object is not the
|
|
595
|
+
// error and stays refused (the F2 boundary refusal in promise handlers).
|
|
596
|
+
function isBareErrReturn(expr, errName) {
|
|
597
|
+
let e = expr
|
|
598
|
+
while (e && e.type === 'AwaitExpression') e = e.argument
|
|
599
|
+
return !!errName && !!e && e.type === 'Identifier' && e.name === errName
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// makeHandledAnalysis: path-sensitive walk of a catch / rejection handler.
|
|
603
|
+
// Per-statement verdict: 'terminal' (no path falls past - throw or boundary
|
|
604
|
+
// return), 'bad' (some path exits unhandled), { reported } (falls through;
|
|
605
|
+
// reported true when every falling path passed the sticky event). Constructs
|
|
606
|
+
// not modeled (switch, loops, nested try) are opaque: a hidden return fails
|
|
607
|
+
// closed, reporters inside do not count. Ported from gmux. returnIdentity
|
|
608
|
+
// credits `return <errName>` as terminal (promise handlers only: the settled
|
|
609
|
+
// value is the error object itself).
|
|
610
|
+
function makeHandledAnalysis(opts) {
|
|
611
|
+
const { context, errName, allowBoundary, returnIdentity } = opts
|
|
612
|
+
function analyzeStmt(stmt, reported) {
|
|
613
|
+
if (!stmt) return { reported }
|
|
614
|
+
if (stmt.type === 'ExpressionStatement') {
|
|
615
|
+
if (isExecutorRejectCall(context, stmt.expression, errName)) return 'terminal'
|
|
616
|
+
return isReporterExpr(context, stmt.expression, errName) ? { reported: true } : { reported }
|
|
617
|
+
}
|
|
618
|
+
if (stmt.type === 'ThrowStatement') return 'terminal'
|
|
619
|
+
if (stmt.type === 'ReturnStatement') {
|
|
620
|
+
if (reported) return 'terminal'
|
|
621
|
+
if (allowBoundary && isBoundaryValue(stmt.argument, errName)) return 'terminal'
|
|
622
|
+
if (returnIdentity && isBareErrReturn(stmt.argument, errName)) return 'terminal'
|
|
623
|
+
return 'bad'
|
|
624
|
+
}
|
|
625
|
+
if (stmt.type === 'BlockStatement') return analyzeList(stmt.body, reported)
|
|
626
|
+
if (stmt.type === 'IfStatement') {
|
|
627
|
+
const c = analyzeStmt(stmt.consequent, reported)
|
|
628
|
+
if (c === 'bad') return 'bad'
|
|
629
|
+
const a = stmt.alternate ? analyzeStmt(stmt.alternate, reported) : { reported }
|
|
630
|
+
if (a === 'bad') return 'bad'
|
|
631
|
+
if (c === 'terminal' && a === 'terminal') return 'terminal'
|
|
632
|
+
return { reported: (c === 'terminal' || c.reported) && (a === 'terminal' || a.reported) }
|
|
633
|
+
}
|
|
634
|
+
return containsReturn(stmt) ? 'bad' : { reported }
|
|
635
|
+
}
|
|
636
|
+
function analyzeList(stmts, reported) {
|
|
637
|
+
for (const stmt of stmts) {
|
|
638
|
+
const r = analyzeStmt(stmt, reported)
|
|
639
|
+
if (r === 'bad' || r === 'terminal') return r
|
|
640
|
+
reported = r.reported
|
|
641
|
+
}
|
|
642
|
+
return { reported }
|
|
643
|
+
}
|
|
644
|
+
function handled(body) {
|
|
645
|
+
if (!body) return false
|
|
646
|
+
if (body.type !== 'BlockStatement') {
|
|
647
|
+
if (isReporterExpr(context, body, errName)) return true
|
|
648
|
+
if (isExecutorRejectCall(context, body, errName)) return true
|
|
649
|
+
if (returnIdentity && isBareErrReturn(body, errName)) return true
|
|
650
|
+
return !!allowBoundary && isBoundaryValue(body, errName)
|
|
651
|
+
}
|
|
652
|
+
const r = analyzeList(body.body, false)
|
|
653
|
+
if (r === 'bad') return false
|
|
654
|
+
return r === 'terminal' || r.reported
|
|
655
|
+
}
|
|
656
|
+
return { handled }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// notifyCaptureConflict: some execution path through `block` both captures (a
|
|
660
|
+
// recognized reporter call) and notifies (a tackbox notify carrying the caught
|
|
661
|
+
// error) - the D006 double-lane, where error/warn already reach the user, so
|
|
662
|
+
// the paired notify double-shows. Path-sensitive: exclusive if/else legs do not
|
|
663
|
+
// pair, nor does a capture after a notify+return. if/else is followed precisely;
|
|
664
|
+
// loops and switch are opaque (their calls may-run). The JS analog of the Go
|
|
665
|
+
// doublelane walk. Each live path carries which lanes have fired; dedup keeps at
|
|
666
|
+
// most four states.
|
|
667
|
+
function notifyCaptureConflict(context, block, errName) {
|
|
668
|
+
let found = false
|
|
669
|
+
const lanesIn = node => {
|
|
670
|
+
let cap = false
|
|
671
|
+
let notify = false
|
|
672
|
+
walk(node, n => {
|
|
673
|
+
if (n.type !== 'CallExpression') return
|
|
674
|
+
if (isReporterCall(context, n, errName)) cap = true
|
|
675
|
+
if (isTier1Notify(context, n) && argFlows(n, errName)) notify = true
|
|
676
|
+
})
|
|
677
|
+
return { cap, notify }
|
|
678
|
+
}
|
|
679
|
+
const dedup = states => {
|
|
680
|
+
const out = []
|
|
681
|
+
const seen = new Set()
|
|
682
|
+
for (const s of states) {
|
|
683
|
+
const key = `${s.cap ? 1 : 0},${s.notify ? 1 : 0}`
|
|
684
|
+
if (!seen.has(key)) {
|
|
685
|
+
seen.add(key)
|
|
686
|
+
out.push(s)
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return out
|
|
690
|
+
}
|
|
691
|
+
const apply = (states, cap, notify) => {
|
|
692
|
+
if (!cap && !notify) return states
|
|
693
|
+
return dedup(
|
|
694
|
+
states.map(s => {
|
|
695
|
+
const ns = { cap: s.cap || cap, notify: s.notify || notify }
|
|
696
|
+
if (ns.cap && ns.notify) found = true
|
|
697
|
+
return ns
|
|
698
|
+
}),
|
|
699
|
+
)
|
|
700
|
+
}
|
|
701
|
+
const step = (st, states) => {
|
|
702
|
+
if (!st) return states
|
|
703
|
+
switch (st.type) {
|
|
704
|
+
case 'BlockStatement':
|
|
705
|
+
return stepList(st.body, states)
|
|
706
|
+
case 'IfStatement': {
|
|
707
|
+
const t = lanesIn(st.test)
|
|
708
|
+
const base = apply(states, t.cap, t.notify)
|
|
709
|
+
const thenExit = step(st.consequent, base)
|
|
710
|
+
const elseExit = st.alternate ? step(st.alternate, base) : base
|
|
711
|
+
return dedup(thenExit.concat(elseExit))
|
|
712
|
+
}
|
|
713
|
+
case 'SwitchStatement': {
|
|
714
|
+
const d = lanesIn(st.discriminant)
|
|
715
|
+
const base = apply(states, d.cap, d.notify)
|
|
716
|
+
// Cases are exclusive legs, but JS falls a terminator-less case into the
|
|
717
|
+
// next: thread each case's fall-through exit into the next case's entry,
|
|
718
|
+
// so only a real fall-through pairs, not two exclusive cases.
|
|
719
|
+
let fall = []
|
|
720
|
+
let sawDefault = false
|
|
721
|
+
for (const sc of st.cases) {
|
|
722
|
+
if (sc.test === null) sawDefault = true
|
|
723
|
+
fall = stepList(sc.consequent, dedup(base.concat(fall)))
|
|
724
|
+
}
|
|
725
|
+
const exits = fall.slice()
|
|
726
|
+
if (!sawDefault) exits.push(...base) // no case matched: base falls through
|
|
727
|
+
return dedup(exits)
|
|
728
|
+
}
|
|
729
|
+
case 'ReturnStatement':
|
|
730
|
+
case 'ThrowStatement': {
|
|
731
|
+
if (st.argument) {
|
|
732
|
+
const { cap, notify } = lanesIn(st.argument)
|
|
733
|
+
apply(states, cap, notify)
|
|
734
|
+
}
|
|
735
|
+
return []
|
|
736
|
+
}
|
|
737
|
+
case 'BreakStatement':
|
|
738
|
+
case 'ContinueStatement':
|
|
739
|
+
return []
|
|
740
|
+
default: {
|
|
741
|
+
const { cap, notify } = lanesIn(st)
|
|
742
|
+
return apply(states, cap, notify)
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const stepList = (stmts, states) => {
|
|
747
|
+
let cur = states
|
|
748
|
+
for (const st of stmts) {
|
|
749
|
+
if (found) return []
|
|
750
|
+
cur = step(st, cur)
|
|
751
|
+
}
|
|
752
|
+
return cur
|
|
753
|
+
}
|
|
754
|
+
stepList(block.body, [{ cap: false, notify: false }])
|
|
755
|
+
return found
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const TEST_ROOTS = new Set(['it', 'test', 'describe'])
|
|
759
|
+
|
|
760
|
+
// matchesTestModifier: does callee name a test-modifier form - a bare alias in
|
|
761
|
+
// bareSet (fit/xit/...) or a member chain (it.only / it.skip) whose leaf
|
|
762
|
+
// property satisfies isModifierProp and whose root is a test root.
|
|
763
|
+
function matchesTestModifier(callee, bareSet, isModifierProp) {
|
|
764
|
+
if (callee.type === 'Identifier') return bareSet.has(callee.name)
|
|
765
|
+
if (callee.type === 'MemberExpression') {
|
|
766
|
+
let cur = callee
|
|
767
|
+
let prop = false
|
|
768
|
+
while (cur && cur.type === 'MemberExpression') {
|
|
769
|
+
if (!cur.computed && cur.property.type === 'Identifier' && isModifierProp(cur.property.name)) prop = true
|
|
770
|
+
cur = cur.object
|
|
771
|
+
}
|
|
772
|
+
return prop && cur.type === 'Identifier' && TEST_ROOTS.has(cur.name)
|
|
773
|
+
}
|
|
774
|
+
return false
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
module.exports = {
|
|
778
|
+
REPORTER_NAMES,
|
|
779
|
+
REPORTER_FULL,
|
|
780
|
+
REPORTER_SYNTH,
|
|
781
|
+
TACKBOX_MODULES,
|
|
782
|
+
DEDUP_KEY_RE,
|
|
783
|
+
TEST_ROOTS,
|
|
784
|
+
calleeName,
|
|
785
|
+
argFlows,
|
|
786
|
+
isTestFile,
|
|
787
|
+
tier1ReporterName,
|
|
788
|
+
isTier1ReporterCall,
|
|
789
|
+
isTier1Notify,
|
|
790
|
+
notifyCaptureConflict,
|
|
791
|
+
resolvesToDeclaredReporter,
|
|
792
|
+
isDeclaredReporterCall,
|
|
793
|
+
isReporterCall,
|
|
794
|
+
isInDeclaredReporterBody,
|
|
795
|
+
isThrowStatement,
|
|
796
|
+
isStaticString,
|
|
797
|
+
staticStringValue,
|
|
798
|
+
walk,
|
|
799
|
+
blockHasThrow,
|
|
800
|
+
blockHasReport,
|
|
801
|
+
hasMarkerAbove,
|
|
802
|
+
enclosingFn,
|
|
803
|
+
fnReturnsResultLike,
|
|
804
|
+
someNode,
|
|
805
|
+
errObjectFlows,
|
|
806
|
+
objectCarriesErr,
|
|
807
|
+
matchesTestModifier,
|
|
808
|
+
makeHandledAnalysis,
|
|
809
|
+
}
|