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,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
+ }