tackbox 0.1.0 → 0.1.63

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/README.md CHANGED
@@ -8,6 +8,15 @@
8
8
 
9
9
  **Every failure must report, propagate, or explain itself.**
10
10
 
11
+ tackbox is a guardrail for developers and coding agents. It catches common
12
+ local ways failures are hidden by accident, haste, or expediency and
13
+ requires an explicit outcome: propagate, report, or explain. It is not a
14
+ whole-program proof or a security boundary.
15
+
16
+ tackbox's lint contract recognizes direct reporting helpers as an explicit
17
+ report outcome. Execution policy, control flow, result ownership, and runtime
18
+ integration remain application decisions.
19
+
11
20
  Coding agents write error handling that looks right and silently
12
21
  isn't: a swallowed exception, a fatal exit with nothing logged, a
13
22
  report with the cause stripped out. tackbox catches it the moment
@@ -15,9 +24,9 @@ it's written: hooked into the agent's edit loop it flags the finding
15
24
  before the turn ends, and the same rules gate pre-commit and CI -
16
25
  one coverage bar for hand-written and agent-written code.
17
26
 
18
- And there is no quiet way around any of it: no flags, no config. The
19
- only escape is an explicit `// no-report: <reason>` at the site - and
20
- the agent hook asks for your approval before a new suppression lands.
27
+ There are no per-rule disable flags. A local exception stays visible as
28
+ a reasoned `// no-report: <reason>` marker at the site, and the agent
29
+ hook asks for your approval before a new suppression lands.
21
30
 
22
31
  ```go
23
32
  resp, err := client.Do(req)
@@ -174,7 +183,7 @@ fetched separately and cached per version:
174
183
 
175
184
  After the first fetch tackbox runs fully offline until the engine
176
185
  version changes. Platform wheels cover Linux x86_64/arm64 (manylinux),
177
- macOS x86_64/arm64, and Windows x86_64. `engines.json` in the thin
186
+ macOS arm64, and Windows x86_64. `engines.json` in the thin
178
187
  wheel records the source, version, sha256, and license of every
179
188
  bundled binary and dependency; `tackbox doctor` fetches the store if
180
189
  absent and verifies the payload against it.
@@ -187,9 +196,8 @@ test-skip rules (via the `pyrules` flake8 plugin), frontend swallow,
187
196
  notify, and test-skip rules (JS, TS, Svelte, via ESLint), and Markdown
188
197
  (MD001-060 + ASCII).
189
198
 
190
- See `go/README.md` for the Go ruleset. The specs these rules implement
191
- (`error-reporting-and-coverage`, `error-handling-frontend`) live
192
- outside this repo (private notes); the public summary:
199
+ See `go/README.md` for the complete Go ruleset. Across supported
200
+ languages, the core policy is:
193
201
 
194
202
  - Every `err != nil` branch must propagate, capture, or carry an
195
203
  explicit `// no-report: <reason>` marker.
@@ -201,8 +209,9 @@ outside this repo (private notes); the public summary:
201
209
  - Bare `return nil` from a single-result function must carry
202
210
  `// nil-return: <reason>` or use `(val, ok)` / `(val, err)`.
203
211
  - A single err-branch may not both capture and `return err`.
204
- - Capture-call arguments must not carry raw user input, and the
205
- dedupKey must be a well-formed literal.
212
+ - The dedupKey must be a well-formed literal; in Go, capture-call
213
+ arguments must additionally not carry raw user input (a
214
+ `*http.Request` field).
206
215
  - A `notify` (user lane only, no capture) may terminate a failure path
207
216
  only when it is narrowed: a narrow catch type (Java/Python) or an
208
217
  additional condition inside the branch (Go/JS). An unconditional
@@ -361,20 +370,11 @@ on a non-Go file is rejected - a dead line would be silent. The format
361
370
  is language-uniform; the restriction lifts as other engines adopt the
362
371
  contract.
363
372
 
364
- ## Deduplication: telemetry, never the user
365
-
366
- Dedup lives at two levels with different owners
367
- (`docs/report-contracts.md` D005):
373
+ ## Runtime reporting helpers
368
374
 
369
- - The capture helpers rate-limit telemetry: repeat captures with the
370
- same dedupKey inside the rate window (default 60s) are not re-sent.
371
- Lossless - the server groups by fingerprint and counts repeats.
372
- - The user lane is never suppressed by the helpers. Every user-facing
373
- event is delivered carrying its dedupKey; collapsing a storm into
374
- one live banner or a counter is presentation policy and belongs to
375
- the app's listener, keyed on that dedupKey. A notification dropped
376
- inside the helper would be a swallowed error at the UI level - the
377
- exact failure mode tackbox exists to prevent.
375
+ Direct reporting helpers ship per language; their shared runtime behavior -
376
+ lane routing, telemetry dedup, panic grouping, and capture isolation - is
377
+ specified in [docs/report-contracts.md](docs/report-contracts.md).
378
378
 
379
379
  ## Agent hook (Claude Code)
380
380
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tackbox",
3
- "version": "0.1.0",
4
- "description": "ESLint plugin + browser report helper: every failure must report, propagate, or explain itself.",
3
+ "version": "0.1.63",
4
+ "description": "ESLint and Markdown lint plugins plus direct error-reporting helpers for JavaScript and TypeScript.",
5
5
  "license": "MIT",
6
6
  "main": "./js/eslint-plugin.js",
7
7
  "exports": {
@@ -13,7 +13,11 @@
13
13
  "tackbox-mdlint": "./bin/tackbox-mdlint.js"
14
14
  },
15
15
  "files": [
16
- "js/",
16
+ "js/eslint-plugin.js",
17
+ "js/report.js",
18
+ "js/rules/",
19
+ "js/markdownlint-rules/",
20
+ "js/README.md",
17
21
  "bin/",
18
22
  "eslint.config.preset.js"
19
23
  ],
@@ -1,53 +0,0 @@
1
- const path = require('node:path')
2
- const os = require('node:os')
3
- const fs = require('node:fs')
4
- const { spawnSync } = require('node:child_process')
5
- const { test } = require('node:test')
6
- const assert = require('node:assert/strict')
7
-
8
- const WRAPPER = path.resolve(__dirname, '..', '..', 'bin', 'tackbox-eslint.js')
9
-
10
- // An empty catch defeated by an inline eslint-disable. The hermetic wrapper must
11
- // ignore the directive (allowInlineConfig: false) so the swallow still fails the
12
- // run: an inline disable is an uninventoried, ungated bypass of every JS rule,
13
- // invisible to `tackbox escapes` and the approval gate.
14
- const DISABLED_SWALLOW = [
15
- 'function handler() {',
16
- ' try {',
17
- ' doThing()',
18
- ' // eslint-disable-next-line tackbox/no-swallow-catch',
19
- ' } catch (e) {',
20
- ' }',
21
- '}',
22
- '',
23
- ].join('\n')
24
-
25
- function inTmpDir(body) {
26
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tackbox-eslint-'))
27
- fs.writeFileSync(path.join(dir, 'bad.js'), DISABLED_SWALLOW)
28
- try {
29
- return body(dir)
30
- } finally {
31
- fs.rmSync(dir, { recursive: true, force: true })
32
- }
33
- }
34
-
35
- function runWrapper(dir, args) {
36
- return spawnSync('node', [WRAPPER, ...args, 'bad.js'], { cwd: dir, encoding: 'utf8' })
37
- }
38
-
39
- test('inline eslint-disable cannot silence a swallow (default mode)', () => {
40
- inTmpDir(dir => {
41
- const r = runWrapper(dir, [])
42
- assert.equal(r.status, 1, r.stdout + r.stderr)
43
- assert.match(r.stdout, /no-swallow-catch/)
44
- })
45
- })
46
-
47
- test('inline eslint-disable cannot silence a swallow (--machine mode)', () => {
48
- inTmpDir(dir => {
49
- const r = runWrapper(dir, ['--machine'])
50
- assert.equal(r.status, 1, r.stdout + r.stderr)
51
- assert.match(r.stdout, /no-swallow-catch/)
52
- })
53
- })
@@ -1,85 +0,0 @@
1
- const { test } = require('node:test')
2
- const assert = require('node:assert/strict')
3
- const { spawnSync } = require('node:child_process')
4
- const { mkdtempSync, writeFileSync, rmSync } = require('node:fs')
5
- const { tmpdir } = require('node:os')
6
- const path = require('node:path')
7
-
8
- const WRAPPER = path.resolve(__dirname, '..', '..', 'bin', 'tackbox-mdlint.js')
9
-
10
- function withTmp(fn) {
11
- const dir = mkdtempSync(path.join(tmpdir(), 'tackbox-mdlint-'))
12
- try { return fn(dir) } finally { rmSync(dir, { recursive: true, force: true }) }
13
- }
14
-
15
- function lintInTmp(dir, file) {
16
- return spawnSync('node', [WRAPPER, file], { cwd: dir, encoding: 'utf8' })
17
- }
18
-
19
- test('ignores consumer .markdownlint.json that disables defaults', () => {
20
- withTmp(dir => {
21
- writeFileSync(path.join(dir, '.markdownlint.json'), '{"default": false}')
22
- writeFileSync(path.join(dir, 'bad.md'), '# hi\n\nrocket: \u{1F680}\n')
23
- const r = lintInTmp(dir, 'bad.md')
24
- assert.equal(r.status, 1, r.stdout + r.stderr)
25
- assert.match(r.stdout, /no-non-ascii/)
26
- assert.match(r.stdout, /U\+1F680/)
27
- })
28
- })
29
-
30
- test('rejects inline markdownlint-disable for the ASCII rule', () => {
31
- withTmp(dir => {
32
- const md = [
33
- '# hi',
34
- '',
35
- '<!-- markdownlint-disable no-non-ascii -->',
36
- '',
37
- 'still flagged: \u{2014}',
38
- '',
39
- ].join('\n')
40
- writeFileSync(path.join(dir, 'bad.md'), md)
41
- const r = lintInTmp(dir, 'bad.md')
42
- assert.equal(r.status, 1, r.stdout + r.stderr)
43
- assert.match(r.stdout, /U\+2014/)
44
- })
45
- })
46
-
47
- test('exits 0 on clean ASCII file', () => {
48
- withTmp(dir => {
49
- writeFileSync(path.join(dir, 'ok.md'), '# clean\n\nplain ASCII only.\n')
50
- const r = lintInTmp(dir, 'ok.md')
51
- assert.equal(r.status, 0, r.stdout + r.stderr)
52
- assert.equal(r.stdout, '')
53
- })
54
- })
55
-
56
- // End-to-end (default rules + noInlineConfig): the lang marker survives the
57
- // real CLI path, not just the rule unit tests.
58
-
59
- const PRIVET = '\u{41F}\u{440}\u{438}\u{432}\u{435}\u{442}' // "Privet" (hello)
60
- const MIR = '\u{43C}\u{438}\u{440}' // "mir" (world)
61
-
62
- test('ru marker: Russian prose passes the real wrapper clean', () => {
63
- withTmp(dir => {
64
- writeFileSync(
65
- path.join(dir, 'ru.md'),
66
- '<!-- tackbox: lang=ru personal repo -->\n\n# notes\n\n' + PRIVET + ' \u{2014} ' + MIR + '.\n'
67
- )
68
- const r = lintInTmp(dir, 'ru.md')
69
- assert.equal(r.status, 0, r.stdout + r.stderr)
70
- assert.equal(r.stdout, '')
71
- })
72
- })
73
-
74
- test('ru marker still flags an emoji, not the Cyrillic', () => {
75
- withTmp(dir => {
76
- writeFileSync(
77
- path.join(dir, 'ru.md'),
78
- '<!-- tackbox: lang=ru note -->\n\n# notes\n\n' + PRIVET + ' \u{1F680}\n'
79
- )
80
- const r = lintInTmp(dir, 'ru.md')
81
- assert.equal(r.status, 1, r.stdout + r.stderr)
82
- assert.match(r.stdout, /U\+1F680/)
83
- assert.doesNotMatch(r.stdout, /U\+41F/)
84
- })
85
- })
@@ -1,124 +0,0 @@
1
- const { test } = require('node:test')
2
- const assert = require('node:assert/strict')
3
- const { lint } = require('markdownlint/promise')
4
- const rule = require('../markdownlint-rules/no-non-ascii')
5
-
6
- async function run(markdown) {
7
- const out = await lint({
8
- strings: { 'in.md': markdown },
9
- config: { default: false, 'no-non-ascii': true },
10
- customRules: [rule],
11
- })
12
- return out['in.md']
13
- }
14
-
15
- test('passes on pure ASCII content', async () => {
16
- const errs = await run([
17
- '# heading',
18
- '',
19
- 'A paragraph with - dash, "quotes", `code`, and 0x7f end.',
20
- '',
21
- '```bash',
22
- 'echo hi',
23
- '```',
24
- ].join('\n'))
25
- assert.equal(errs.length, 0)
26
- })
27
-
28
- test('flags assorted non-ASCII (em-dash, curly, Cyrillic, box)', async () => {
29
- const errs = await run('em \u{2014} curly \u{201C}x\u{201D} hi \u{43F}\n\u{251C}\n')
30
- const hexes = errs.map(e => e.errorDetail.match(/U\+([0-9A-F]+)/)[1])
31
- assert.deepEqual(hexes, ['2014', '201C', '201D', '43F', '251C'])
32
- })
33
-
34
- test('flags non-ASCII inside fenced code blocks', async () => {
35
- const errs = await run('```text\nhello \u{2014} world\n```\n')
36
- assert.equal(errs.length, 1)
37
- assert.equal(errs[0].lineNumber, 2)
38
- })
39
-
40
- test('reports column position and astral codepoints', async () => {
41
- const errs = await run('abc\u{1F680}xyz\n')
42
- assert.equal(errs.length, 1)
43
- assert.match(errs[0].errorDetail, /U\+1F680/)
44
- assert.deepEqual(errs[0].errorRange, [4, 2])
45
- })
46
-
47
- // -- lang marker ----------------------------------------------------------
48
-
49
- // Cyrillic building blocks, written as \u escapes so this source stays ASCII.
50
- const PRIVET = '\u{41F}\u{440}\u{438}\u{432}\u{435}\u{442}' // "Privet" (hello)
51
- const MIR = '\u{43C}\u{438}\u{440}' // "mir" (world)
52
-
53
- function detailHexes(errs) {
54
- return errs.map(e => e.errorDetail.match(/U\+([0-9A-F]+)/)[1])
55
- }
56
-
57
- test('ru marker widens the alphabet: Russian prose + typography + ASCII code -> clean', async () => {
58
- const md = [
59
- '<!-- tackbox: lang=ru personal experimental repo -->',
60
- '# ' + PRIVET,
61
- '',
62
- // em-dash, guillemets, ellipsis, NBSP all allowed under ru
63
- PRIVET + ' \u{2014} \u{AB}' + MIR + '\u{BB}\u{2026}\u{A0}end',
64
- '',
65
- '```bash',
66
- 'echo hi',
67
- '```',
68
- ].join('\n')
69
- const errs = await run(md)
70
- assert.equal(errs.length, 0, JSON.stringify(errs))
71
- })
72
-
73
- test('ru marker still flags emoji and CJK, and only those', async () => {
74
- const md = [
75
- '<!-- tackbox: lang=ru note -->',
76
- PRIVET + ' \u{1F680} \u{4E2D}', // rocket + CJK amid allowed Cyrillic
77
- ].join('\n')
78
- const errs = await run(md)
79
- assert.deepEqual(detailHexes(errs), ['1F680', '4E2D'])
80
- })
81
-
82
- test('no marker: Cyrillic is still flagged (default unchanged)', async () => {
83
- const errs = await run(PRIVET + '\n')
84
- assert.deepEqual(detailHexes(errs), ['41F', '440', '438', '432', '435', '442'])
85
- })
86
-
87
- test('marker below line 5 is a finding and does not widen', async () => {
88
- const md = [
89
- 'line 1', 'line 2', 'line 3', 'line 4', 'line 5',
90
- '<!-- tackbox: lang=ru too late -->',
91
- PRIVET, // line 7
92
- ].join('\n')
93
- const errs = await run(md)
94
- const placement = errs.find(e => /within the first 5 lines/.test(e.errorDetail))
95
- assert.ok(placement, 'expected a marker-placement finding: ' + JSON.stringify(errs))
96
- assert.equal(placement.lineNumber, 6)
97
- // File stays ASCII-only, so the Cyrillic on line 7 is still flagged.
98
- assert.ok(errs.some(e => e.lineNumber === 7 && /U\+41F/.test(e.errorDetail)))
99
- })
100
-
101
- test('duplicate marker is a finding and leaves the file ASCII-only', async () => {
102
- const md = [
103
- '<!-- tackbox: lang=ru first -->',
104
- '<!-- tackbox: lang=ru second -->',
105
- PRIVET,
106
- ].join('\n')
107
- const errs = await run(md)
108
- const dup = errs.filter(e => /duplicate tackbox lang marker/.test(e.errorDetail))
109
- assert.equal(dup.length, 1)
110
- assert.equal(dup[0].lineNumber, 2)
111
- assert.ok(errs.some(e => /U\+41F/.test(e.errorDetail)), 'Cyrillic still flagged')
112
- })
113
-
114
- test('unknown language code is a finding and does not widen', async () => {
115
- const errs = await run('<!-- tackbox: lang=xx -->\n' + PRIVET + '\n')
116
- assert.ok(errs.some(e => /unsupported language code 'xx'/.test(e.errorDetail)))
117
- assert.ok(errs.some(e => /U\+41F/.test(e.errorDetail)))
118
- })
119
-
120
- test('marker with no language code is a finding and does not widen', async () => {
121
- const errs = await run('<!-- tackbox: lang= -->\n' + PRIVET + '\n')
122
- assert.ok(errs.some(e => /missing a language code/.test(e.errorDetail)))
123
- assert.ok(errs.some(e => /U\+41F/.test(e.errorDetail)))
124
- })
@@ -1,182 +0,0 @@
1
- const { test } = require('node:test')
2
- const assert = require('node:assert')
3
-
4
- // report.js reads console, window/CustomEvent, and @sentry/browser at call time
5
- // (globals + the shared module cache), so replacing them here reaches the module
6
- // under test. A fake Sentry in require.cache dodges real init/network; load()
7
- // reloads report.js per scenario for pristine ready/lastSent state.
8
- const REPORT_PATH = require.resolve('../report.js')
9
- const SENTRY_PATH = require.resolve('@sentry/browser')
10
-
11
- console.log = () => {}
12
- console.warn = () => {}
13
- console.error = () => {}
14
-
15
- let captured = [] // one entry per Sentry.captureException: { err, level, fingerprint, tags }
16
- let dispatched = [] // one entry per window.dispatchEvent: the CustomEvent detail
17
- let scope = null
18
-
19
- const fakeSentry = {
20
- init() {},
21
- withScope(fn) {
22
- scope = { level: undefined, fingerprint: undefined, tags: {} }
23
- fn({
24
- setLevel(l) { scope.level = l },
25
- setFingerprint(f) { scope.fingerprint = f },
26
- setTag(k, v) { scope.tags[k] = v },
27
- })
28
- scope = null
29
- },
30
- captureException(err) {
31
- captured.push({
32
- err,
33
- level: scope && scope.level,
34
- fingerprint: scope && scope.fingerprint,
35
- tags: scope ? { ...scope.tags } : null,
36
- })
37
- },
38
- }
39
- require.cache[SENTRY_PATH] = { id: SENTRY_PATH, filename: SENTRY_PATH, loaded: true, exports: fakeSentry }
40
-
41
- class FakeCustomEvent {
42
- constructor(type, opts) {
43
- this.type = type
44
- this.detail = opts && opts.detail
45
- }
46
- }
47
- Object.defineProperty(globalThis, 'CustomEvent', { value: FakeCustomEvent, configurable: true, writable: true })
48
- Object.defineProperty(globalThis, 'window', {
49
- value: { addEventListener() {}, dispatchEvent(ev) { dispatched.push(ev.detail); return true } },
50
- configurable: true,
51
- writable: true,
52
- })
53
-
54
- const VALID_DSN = 'https://public@sentry.example.com/1'
55
-
56
- function load() {
57
- captured = []
58
- dispatched = []
59
- scope = null
60
- delete require.cache[REPORT_PATH]
61
- return require(REPORT_PATH)
62
- }
63
-
64
- // (a) DSN unset -> not ready -> capture disabled, but the user lane still fires.
65
- test('report dispatches to the user lane even when not ready (DSN unset)', () => {
66
- const report = load()
67
- report.init({})
68
- assert.equal(report.isReady(), false)
69
- report.reportError('connection lost mid-stream', new Error('boom'), { area: 'net' }, 'net.conn')
70
- assert.equal(dispatched.length, 1, 'user lane must dispatch without init')
71
- assert.equal(captured.length, 0, 'capture stays gated off when not ready')
72
- assert.equal(dispatched[0].msg, 'connection lost mid-stream')
73
- assert.equal(dispatched[0].dedupKey, 'net.conn')
74
- })
75
-
76
- // (b) same dedupKey inside the rate window: both dispatch, second capture dropped.
77
- test('rate window drops the second capture but never the dispatch', () => {
78
- const report = load()
79
- report.init({ dsn: VALID_DSN })
80
- assert.equal(report.isReady(), true)
81
- report.reportError('poll failed on stale token', new Error('e1'), { area: 'poll' }, 'poll.stale')
82
- report.reportError('poll failed on stale token', new Error('e2'), { area: 'poll' }, 'poll.stale')
83
- assert.equal(dispatched.length, 2, 'every event reaches the user lane')
84
- assert.equal(captured.length, 1, 'duplicate capture suppressed within the window')
85
- })
86
-
87
- // (D-5) re-entrancy guard: a throwing tackbox:error listener surfaces via
88
- // window.onerror, which setupGlobalHandlers turns back into reportError ->
89
- // dispatch on the same stack. The guard skips the nested dispatch so it cannot
90
- // loop; sequential dispatches are unaffected.
91
- test('a re-entering dispatch is skipped (no infinite loop, one outer dispatch)', () => {
92
- const report = load()
93
- report.init({})
94
- let calls = 0
95
- const realWindow = window
96
- Object.defineProperty(globalThis, 'window', {
97
- value: {
98
- addEventListener() {},
99
- dispatchEvent(ev) {
100
- calls++
101
- dispatched.push(ev.detail)
102
- // the listener failure re-enters synchronously via reportError
103
- report.reportError('re-entry from a listener failure', new Error('inner'), null, 'reentry.key')
104
- return true
105
- },
106
- },
107
- configurable: true,
108
- writable: true,
109
- })
110
- try {
111
- report.reportError('outer dispatch that re-enters', new Error('outer'), null, 'outer.key')
112
- assert.equal(calls, 1, 'exactly one dispatch; the nested re-entry is skipped')
113
- assert.equal(dispatched.length, 1, 'only the outer notice reaches the user lane')
114
- } finally {
115
- Object.defineProperty(globalThis, 'window', { value: realWindow, configurable: true, writable: true })
116
- }
117
- })
118
-
119
- // (c) reportPanic routes to the user lane with level fatal and a per-name key.
120
- test('reportPanic dispatches level fatal with panic:<name> dedupKey', () => {
121
- const report = load()
122
- report.reportPanic('worker', new Error('kaboom'))
123
- assert.equal(dispatched.length, 1)
124
- const d = dispatched[0]
125
- assert.equal(d.level, 'fatal')
126
- assert.equal(d.dedupKey, 'panic:worker')
127
- assert.equal(d.msg, 'panic in worker')
128
- assert.equal(d.cause.message, 'kaboom')
129
- })
130
-
131
- // (d) capture builds Error(msg) with the original error chained as .cause (seed shape).
132
- test('capture is Error(msg) with the original cause chained', () => {
133
- const report = load()
134
- report.init({ dsn: VALID_DSN })
135
- const original = new Error('socket hangup')
136
- report.reportError('upload failed mid-flight', original, { area: 'upload' }, 'upload.fail')
137
- assert.equal(captured.length, 1)
138
- const { err, level, fingerprint, tags } = captured[0]
139
- assert.ok(err instanceof Error)
140
- assert.equal(err.message, 'upload failed mid-flight')
141
- assert.equal(err.cause, original)
142
- assert.equal(level, 'error')
143
- assert.deepEqual(fingerprint, ['upload.fail'])
144
- assert.equal(tags.area, 'upload')
145
- })
146
-
147
- // (e) reportSynthError captures a plain Error(msg) with no cause chain.
148
- test('reportSynthError captures Error(msg) with no cause', () => {
149
- const report = load()
150
- report.init({ dsn: VALID_DSN })
151
- report.reportSynthError('non-OK response handled inline', { area: 'http' }, 'http.synth')
152
- assert.equal(captured.length, 1)
153
- assert.equal(captured[0].err.message, 'non-OK response handled inline')
154
- assert.equal(captured[0].err.cause, undefined)
155
- assert.equal(dispatched.length, 1)
156
- assert.equal(dispatched[0].cause, null, 'synth user-lane detail carries cause: null')
157
- })
158
-
159
- // (f) reportQuiet captures at warning with no user-lane dispatch.
160
- test('reportQuiet captures warning-level with no user-lane dispatch', () => {
161
- const report = load()
162
- report.init({ dsn: VALID_DSN })
163
- report.reportQuiet('index rebuild degraded, using stale', new Error('timeout'), { area: 'idx' }, 'idx.stale')
164
- assert.equal(captured.length, 1, 'quiet still captures')
165
- assert.equal(captured[0].level, 'warning')
166
- assert.deepEqual(captured[0].fingerprint, ['idx.stale'])
167
- assert.equal(dispatched.length, 0, 'quiet must not touch the user lane')
168
- })
169
-
170
- // (g) notify dispatches only, captures nothing, and consumes no rate slot.
171
- test('notify dispatches level notice, captures nothing, leaves the rate slot', () => {
172
- const report = load()
173
- report.init({ dsn: VALID_DSN })
174
- report.notify('you appear to be offline', new Error('net down'), { area: 'conn' }, 'conn.offline')
175
- assert.equal(dispatched.length, 1)
176
- assert.equal(dispatched[0].level, 'notice')
177
- assert.equal(captured.length, 0, 'notify captures nothing')
178
- // Same dedupKey still captures: notify consumed no rate slot.
179
- report.reportError('still offline after retry', new Error('net down'), { area: 'conn' }, 'conn.offline')
180
- assert.equal(captured.length, 1, 'following reportError on the notify key still captures')
181
- assert.equal(dispatched.length, 2)
182
- })