tackbox 0.1.0 → 0.1.62

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
@@ -174,7 +174,7 @@ fetched separately and cached per version:
174
174
 
175
175
  After the first fetch tackbox runs fully offline until the engine
176
176
  version changes. Platform wheels cover Linux x86_64/arm64 (manylinux),
177
- macOS x86_64/arm64, and Windows x86_64. `engines.json` in the thin
177
+ macOS arm64, and Windows x86_64. `engines.json` in the thin
178
178
  wheel records the source, version, sha256, and license of every
179
179
  bundled binary and dependency; `tackbox doctor` fetches the store if
180
180
  absent and verifies the payload against it.
@@ -187,9 +187,8 @@ test-skip rules (via the `pyrules` flake8 plugin), frontend swallow,
187
187
  notify, and test-skip rules (JS, TS, Svelte, via ESLint), and Markdown
188
188
  (MD001-060 + ASCII).
189
189
 
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:
190
+ See `go/README.md` for the complete Go ruleset. Across supported
191
+ languages, the core policy is:
193
192
 
194
193
  - Every `err != nil` branch must propagate, capture, or carry an
195
194
  explicit `// no-report: <reason>` marker.
@@ -201,8 +200,9 @@ outside this repo (private notes); the public summary:
201
200
  - Bare `return nil` from a single-result function must carry
202
201
  `// nil-return: <reason>` or use `(val, ok)` / `(val, err)`.
203
202
  - 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.
203
+ - The dedupKey must be a well-formed literal; in Go, capture-call
204
+ arguments must additionally not carry raw user input (a
205
+ `*http.Request` field).
206
206
  - A `notify` (user lane only, no capture) may terminate a failure path
207
207
  only when it is narrowed: a narrow catch type (Java/Python) or an
208
208
  additional condition inside the branch (Go/JS). An unconditional
@@ -366,9 +366,11 @@ contract.
366
366
  Dedup lives at two levels with different owners
367
367
  (`docs/report-contracts.md` D005):
368
368
 
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.
369
+ - The capture helpers rate-limit telemetry: a repeat capture with the
370
+ same dedupKey inside the rate window (default 60s) is dropped
371
+ client-side, so the server never sees it. Lossy for in-window repeats
372
+ (their occurrence count and any changed context are lost); captures
373
+ that pass the window reach the server, which groups by fingerprint.
372
374
  - The user lane is never suppressed by the helpers. Every user-facing
373
375
  event is delivered carrying its dedupKey; collapsing a storm into
374
376
  one live banner or a counter is presentation policy and belongs to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tackbox",
3
- "version": "0.1.0",
3
+ "version": "0.1.62",
4
4
  "description": "ESLint plugin + browser report helper: every failure must report, propagate, or explain itself.",
5
5
  "license": "MIT",
6
6
  "main": "./js/eslint-plugin.js",
@@ -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
- })