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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nikita Tsymbal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,552 @@
1
+ # tackbox
2
+
3
+ ![tackbox logo](assets/logo-round.png)
4
+
5
+ [![publish](https://github.com/nikitatsym/tackbox/actions/workflows/publish.yml/badge.svg)](https://github.com/nikitatsym/tackbox/actions/workflows/publish.yml)
6
+ [![verify-release](https://github.com/nikitatsym/tackbox/actions/workflows/verify-release.yml/badge.svg)](https://github.com/nikitatsym/tackbox/actions/workflows/verify-release.yml)
7
+ [![pypi](https://raw.githubusercontent.com/nikitatsym/tackbox/badges/pypi.svg)](https://pypi.org/project/tackbox/)
8
+
9
+ **Every failure must report, propagate, or explain itself.**
10
+
11
+ Coding agents write error handling that looks right and silently
12
+ isn't: a swallowed exception, a fatal exit with nothing logged, a
13
+ report with the cause stripped out. tackbox catches it the moment
14
+ it's written: hooked into the agent's edit loop it flags the finding
15
+ before the turn ends, and the same rules gate pre-commit and CI -
16
+ one coverage bar for hand-written and agent-written code.
17
+
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.
21
+
22
+ ```go
23
+ resp, err := client.Do(req)
24
+ if err != nil {
25
+ return nil // looks handled; the failure just vanished
26
+ }
27
+ ```
28
+
29
+ ```text
30
+ client.go:42: ERC001: err-branch must propagate, capture, or carry
31
+ the error into a terminal exit (err=err)
32
+ ```
33
+
34
+ One command brings the whole stack across Go, Python, Java, JS, TS,
35
+ Svelte, and Markdown - no `go install`, no `npm i`, no external
36
+ `opengrep`:
37
+
38
+ ```bash
39
+ uvx tackbox@latest lint .
40
+ ```
41
+
42
+ The wheel is hermetic: a consumer needs only `git` on PATH (plus a Go
43
+ toolchain if the repo has `.go` files, and a Java 17+ runtime if it
44
+ has `.java` files) and, the first time a given engine version runs,
45
+ network access to fetch the engine payload once.
46
+ Rules roll out via `@latest` - a new safety rule reaches every repo on
47
+ its next run.
48
+
49
+ ## What it catches
50
+
51
+ - **Swallowed errors** - the `catch {}` or `if err != nil { return nil }`
52
+ that makes a failure vanish. Every path must report, propagate, or
53
+ carry an explicit `// no-report: <reason>`.
54
+ - **Silent exits** - `os.Exit`, `log.Fatal`, `System.exit`, or a local
55
+ `die` reached with an unreported error, so the process dies and your
56
+ error tracker never hears about it.
57
+ - **Double reports** - capturing an error *and* re-throwing it, so the
58
+ same failure hits Sentry/glitchtip twice and drowns the signal.
59
+ - **Broken cause chains** - a new exception thrown from a `catch` that
60
+ drops the original (only its message survives), erasing the stack
61
+ you'd actually debug from.
62
+ - **Silently killed tests** - the `it.skip` with no explanation, the
63
+ failing test reborn as `test.todo`, the `it.only` that quietly turns
64
+ off the rest of the suite. Every skip must state a reason; focused
65
+ tests are always an error.
66
+
67
+ ## Wiring into a repo
68
+
69
+ Call `tackbox lint` from the repo's `dev.py lint`, next to the
70
+ project's own linters:
71
+
72
+ ```python
73
+ def lint():
74
+ sh("uvx tackbox@latest lint .")
75
+ sh("uv run ruff check .") # project-owned, if Python
76
+ ```
77
+
78
+ Pre-commit runs a single language-agnostic hook; `dev.py check`
79
+ (= lint + test) decides what to scan:
80
+
81
+ ```yaml
82
+ # .pre-commit-config.yaml in the consumer repo
83
+ repos:
84
+ - repo: local
85
+ hooks:
86
+ - id: dev-check
87
+ name: dev.py check
88
+ entry: python3
89
+ args: [dev.py, check]
90
+ language: system
91
+ pass_filenames: false
92
+ always_run: true
93
+ ```
94
+
95
+ ## CodeClimate report
96
+
97
+ `tackbox lint --codequality <path>` also writes a CodeClimate-format JSON
98
+ array of every finding to `<path>` (console output and exit code unchanged;
99
+ the report is written even when findings exist). Wire it into GitLab CI as a
100
+ `codequality` report so the MR widget renders the findings:
101
+
102
+ ```yaml
103
+ lint:
104
+ script: uvx tackbox@latest lint . --codequality gl-code-quality.json
105
+ artifacts:
106
+ reports:
107
+ codequality: gl-code-quality.json
108
+ ```
109
+
110
+ ## Lint scope and flags
111
+
112
+ `tackbox lint [path] [flags]` scans the git-tracked source set. The
113
+ positional `path` (default `.`) narrows the scan to a subtree; a path
114
+ matching no file in the source set is a usage error (exit 2).
115
+
116
+ - **`--changed`** limits the scan to the dirty tree: files staged,
117
+ unstaged, or untracked.
118
+ - **`--since <ref>`** limits it to the three-dot diff `<ref>...HEAD`
119
+ (what this branch changed since its merge-base with `<ref>`) unioned
120
+ with the dirty tree, so it already covers `--changed`; passing both
121
+ is the same scope as `--since` alone. An unknown ref, or a repo with
122
+ no commits yet, is a usage error (exit 2), not a crash.
123
+ - **`--no-cache`** ignores the per-`(unit, engine)` result cache for
124
+ this run and writes nothing back to it.
125
+
126
+ The `path` scope and the change filters compose:
127
+ `tackbox lint src --changed` lints only the dirty files under `src/`.
128
+
129
+ This scope filter is unrelated to the `escapes` command's `--since`
130
+ `<rev>`, which selects inventory entries new against a revision.
131
+
132
+ ## Exit codes
133
+
134
+ Across commands, `2` is a usage or setup error the command cannot run
135
+ past (argparse misuse, and the per-command cases below).
136
+
137
+ - **lint** - `0` clean, `1` one or more findings, `2` a scope matching
138
+ no files or a git/engine setup failure (a bad `--changed` / `--since`
139
+ ref; an engine-store, reporters, or `go list` error). A closed
140
+ downstream pipe (`lint | head`) exits `141`; `--codequality` never
141
+ changes the code.
142
+ - **doctor** - `0` all checks pass, `1` at least one failed; every
143
+ check always runs (no short-circuit).
144
+ - **hook** - `0` a no-op, a clean re-lint, or a JSON decision (a
145
+ PreToolUse approval prompt or a PostToolUse Bash block); `1` a
146
+ non-blocking infra error (unreadable stdin, a git failure); `2` a
147
+ PostToolUse finding on the edited lines or a non-compiling Go
148
+ package, which blocks the edit in-loop.
149
+ - **escapes** - `0` whenever it runs, entries or not (an inventory,
150
+ not a gate); `1` only for a bad `--since` rev.
151
+
152
+ ## Distribution
153
+
154
+ `uvx tackbox@latest` installs one small wheel; the engine payload is
155
+ fetched separately and cached per version:
156
+
157
+ - `tackbox` (thin) - the Python CLI (including the `pyrules` flake8
158
+ plugin), the `erclint` / `erclint-opengrep` binaries, the
159
+ `javalint.jar`, the opengrep rule yamls, and the ESLint and
160
+ markdownlint plugins and presets. Platform-specific, bumped on every
161
+ push.
162
+ - `tackbox-engines` (fat, ~350 MB unpacked) - the bundled Node
163
+ runtime, the `opengrep` binary, and the vendored third-party
164
+ `node_modules`. Published as a PyPI wheel but **not** a pip
165
+ dependency of thin. On the first run for a given engine version,
166
+ tackbox resolves the wheel via the PyPI JSON API, verifies its
167
+ unpacked payload against the tree sha256 pinned in the thin
168
+ wheel's `engines.json`, and
169
+ unpacks it once into `$XDG_DATA_HOME/tackbox/engines/<version>/`
170
+ (default `~/.local/share/...`; override `TACKBOX_ENGINES_DIR`).
171
+ Every later thin version reuses that one copy, so a stream of
172
+ `@latest` patch bumps never re-materializes the engines. Bumped only
173
+ when an engine changes.
174
+
175
+ After the first fetch tackbox runs fully offline until the engine
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
178
+ wheel records the source, version, sha256, and license of every
179
+ bundled binary and dependency; `tackbox doctor` fetches the store if
180
+ absent and verifies the payload against it.
181
+
182
+ ## What the rules enforce
183
+
184
+ Covers ERC001-009 (Go, via `erclint`), JV001-010 (Java, via the native
185
+ `javalint` engine; JV008 is retired), Python exception, notify, and
186
+ test-skip rules (via the `pyrules` flake8 plugin), frontend swallow,
187
+ notify, and test-skip rules (JS, TS, Svelte, via ESLint), and Markdown
188
+ (MD001-060 + ASCII).
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:
193
+
194
+ - Every `err != nil` branch must propagate, capture, or carry an
195
+ explicit `// no-report: <reason>` marker.
196
+ - Common parser results that fall through to `nil` must capture or
197
+ carry `// parse-skip: <reason>`.
198
+ - Terminal exits (`log.Fatal*`, `os.Exit`, project-local `die`) must
199
+ be preceded by a capture call or carry a `// no-report: <reason>`
200
+ marker (e.g. for the normal `os.Exit(0)` at the end of main).
201
+ - Bare `return nil` from a single-result function must carry
202
+ `// nil-return: <reason>` or use `(val, ok)` / `(val, err)`.
203
+ - 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.
206
+ - A `notify` (user lane only, no capture) may terminate a failure path
207
+ only when it is narrowed: a narrow catch type (Java/Python) or an
208
+ additional condition inside the branch (Go/JS). An unconditional
209
+ notify in a broad catch routes every failure to a toast and blinds
210
+ telemetry - a finding. A single path may not both capture and notify
211
+ (error/warn already reach the user, so the pair double-shows). A
212
+ `notify` is validated like a capture: static-literal msg, well-formed
213
+ literal dedupKey.
214
+ - A skipped test must state a reason: `t.Skip("why")` / `t.Skipf`, or
215
+ `// test-skip: <reason>` above a bare `t.SkipNow()`. The same
216
+ contract holds in every language (skip / todo / xfail /
217
+ `@Disabled`); focused tests (`it.only`, `fit`) are an unconditional
218
+ error.
219
+
220
+ The same model is enforced beyond Go:
221
+
222
+ - **Java** (`javalint`, JV001-010) on a typed javaparser AST: JV001
223
+ swallow (every catch path must propagate, report, print, or carry
224
+ `// no-report`), JV002 chain (a thrown exception must carry the
225
+ caught as its cause), JV003 throwable (a catch of `Throwable` /
226
+ `Error` must rethrow), JV004 useless-catch (a catch that only
227
+ rethrows the caught unchanged - deleted, not annotated), JV005 exit
228
+ (`System.exit` in a catch needs a preceding capture; port of ERC003),
229
+ JV006 double-capture (no path may both report and rethrow; port of
230
+ ERC005 - and no path may both capture and notify), JV007 skip
231
+ (`@Disabled` / `@Ignore` must carry a non-empty reason string), JV009
232
+ notify gate (a notify in a broad catch must narrow the type), and
233
+ JV010 reporter args (a Report user-lane verb needs a static-literal
234
+ msg and a well-formed literal dedupKey). JV008 is retired.
235
+ - **Python** exception and test-skip rules ship as the `pyrules`
236
+ flake8 plugin (`TBX` codes). A skip reason is accepted in any of
237
+ the natural forms: `@pytest.mark.skip(reason=...)`,
238
+ `@pytest.mark.skipif(cond, reason=...)`,
239
+ `@pytest.mark.xfail(reason=...)`, `pytest.skip(...)`, or
240
+ `@unittest.skip(...)`. `contextlib.suppress` is flagged as a
241
+ cosmetic dodge of the swallow rule; the one allowlisted use is
242
+ `asyncio.CancelledError` around `await task` after `task.cancel()`,
243
+ where the CancelledError on the await IS the confirmation that the
244
+ cancel propagated, not an error to log. The notify gate (TBX010) and
245
+ the user-lane argument contract - static-literal msg, well-formed
246
+ `dedup_key` (TBX011) - apply to the `tackbox_report` verbs recognized
247
+ by import origin (D010).
248
+ - **JS / TS / Svelte** swallow and test-skip rules run under ESLint.
249
+ A skip reason is accepted in the call itself: node:test options
250
+ (`{ skip: 'reason' }` / `{ todo: 'reason' }`) and Playwright's
251
+ `test.skip(cond, 'reason')` / `test.fixme(cond, 'reason')`. The
252
+ notify gate is `no-broad-notify` (a notify must sit under a condition
253
+ inside the catch); `valid-error-report` and `valid-dedup-key` also
254
+ validate `notify`'s msg and dedupKey.
255
+
256
+ ### Python rules (TBX001-011)
257
+
258
+ The `pyrules` flake8 plugin emits these codes; each maps to a stable
259
+ rule id (parity with the pre-migration ids).
260
+
261
+ | Code | Rule | Summary |
262
+ | --- | --- | --- |
263
+ | TBX001 | swallowed-exception | propagate or wrap via `raise ... from e` |
264
+ | TBX002 | suppress-exception | restructure so it can't raise |
265
+ | TBX003 | bare-except | catch a specific type, not bare |
266
+ | TBX004 | reraise-without-cause | keep the cause via `raise ... from e` |
267
+ | TBX005 | useless-except | drop a try/except that only re-raises |
268
+ | TBX006 | import-inside-function | move the import to module top |
269
+ | TBX007 | exit-in-except | don't `sys.exit` in except; propagate |
270
+ | TBX008 | test-skip | a skipped/xfailed test needs a reason |
271
+ | TBX010 | notify-lane | notify needs a narrow except type |
272
+ | TBX011 | reporter-args | literal msg and dedup key; data in cause/tags |
273
+
274
+ Full ids carry the `python-` prefix (e.g. `python-swallowed-exception`).
275
+ TBX009 is retired (the removed secret-name heuristic, D001), as JV008 is.
276
+
277
+ ### Duplication (DUP001, DUP002)
278
+
279
+ The `tackbox-jscpd` engine wraps a copy/paste detector and runs by
280
+ default over Go, Python, Java, and the JS family (`.js`, `.jsx`, `.mjs`,
281
+ `.cjs`, `.ts`, `.tsx`, `.svelte`); Markdown is excluded, since prose
282
+ repetition is not a defect. A consumer on `@latest` gets it in CI with
283
+ no wiring.
284
+
285
+ - **DUP001** flags a duplicated block - a clone of at least 50 tokens.
286
+ Both ends are reported, each a finding at its own site, naming the
287
+ counterpart block and the token count.
288
+ - **DUP002** flags a native `jscpd:ignore` marker. That channel would
289
+ bypass the gated suppression below, so its presence alone is a
290
+ finding; remove it.
291
+
292
+ Suppress one clone with a standalone `// dup-ok: <reason>` comment
293
+ directly above the block - a `#` or a single-line `/* ... */` comment
294
+ works per language. The reason must be at least 10 characters (D009),
295
+ and a trailing comment after code does not count. `dup-ok` above one
296
+ end drops only that end; above both ends it drops the whole clone.
297
+
298
+ Duplication is cross-file, so the engine is never cached: it runs on
299
+ every lint and writes no clean-cache markers. A `java`-format clone that
300
+ lies entirely within both files' headers (package, imports, leading
301
+ comments) has no extractable code and is dropped before it is reported.
302
+
303
+ ### Markdown: ASCII and the language marker
304
+
305
+ The Markdown engine runs the standard markdownlint rules plus `MD-ASCII`,
306
+ which flags any non-ASCII character (any codepoint above U+007F) - em
307
+ dashes, curly quotes, other scripts, emoji - keeping docs portable and
308
+ grep-friendly.
309
+
310
+ One HTML comment on one of the first five lines widens the alphabet for
311
+ a single file:
312
+
313
+ ```text
314
+ <!-- tackbox: lang=ru personal experimental repo -->
315
+ ```
316
+
317
+ It widens the allowed set to that language's script plus its typographic
318
+ punctuation - `ru` today: Cyrillic, guillemets, em/en dash, ellipsis,
319
+ curly quotes, NBSP - and nothing else: every other non-ASCII character,
320
+ emoji and other scripts included, is still flagged.
321
+
322
+ The marker is single-use and never disables the rule. A second marker, a
323
+ marker past the fifth line, a missing code, or an unknown language code
324
+ is itself a finding and leaves the whole file strict ASCII. Any text
325
+ after the code (as above) is a free-form note.
326
+
327
+ ## No configuration
328
+
329
+ By design, the ruleset is a single non-negotiable bundle. There are
330
+ no flags to disable individual rules. Suppressing a finding requires
331
+ the explicit per-site marker (`// no-report`, `// parse-skip`,
332
+ `// nil-return`, `// test-skip`, `// dup-ok`) with a reason of at
333
+ least 10 characters - non-empty was too cheap (`ok` / `todo` passed).
334
+
335
+ Capture helpers are recognized by origin, not by name: a Go call
336
+ counts only when its callee resolves (type info / import) to the
337
+ `github.com/nikitatsym/tackbox/go/report` package, a JS/TS call to
338
+ `tackbox/report`, and a Java capture when the caught reaches a
339
+ `nl.tsym.tackbox.report.Report` call or a known logger sink (e.g.
340
+ slf4j, `java.lang.System.Logger`) at `ERROR` / `WARNING` - tier-1.
341
+ Every language also honors a function declared in a repo-root
342
+ `.tackbox-reporters` file (`file#function: reason`) - tier-2. A
343
+ declaration names a report sink - it is not an exclude: it disables no
344
+ rule, and a declared call is honored only when the caught error flows
345
+ into its arguments. Python resolves tier-1 by import origin too (D010),
346
+ scoped to the fixed `tackbox_report` package (`report_error` /
347
+ `report_warn` / `report_quiet` / `report_panic` / `notify`): a call
348
+ counts only when it resolves through the module's own import bindings -
349
+ `from tackbox_report import ...` or `import tackbox_report` (attribute
350
+ form included) - so a same-named local def or a foreign import is not
351
+ the verb. Only its tier-2 declarations stay matched by function name
352
+ (any same-named call), not by resolving the callee to its file.
353
+
354
+ A `[usage]` declaration (`file#function [usage]: reason`) names the
355
+ opposite lane: a deliberate user-facing diagnostic exit, e.g. a CLI
356
+ `usage()` helper. It is never a capture. Its calls are clean outside
357
+ err-branches (nothing failed - no marker needed) and a finding inside
358
+ one (wrong sink for a failure path), regardless of arguments. Only
359
+ erclint (ERC003) consumes usage sinks today, so a `[usage]` declaration
360
+ on a non-Go file is rejected - a dead line would be silent. The format
361
+ is language-uniform; the restriction lifts as other engines adopt the
362
+ contract.
363
+
364
+ ## Deduplication: telemetry, never the user
365
+
366
+ Dedup lives at two levels with different owners
367
+ (`docs/report-contracts.md` D005):
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.
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.
378
+
379
+ ## Agent hook (Claude Code)
380
+
381
+ `tackbox hook` wires the rules into an agent's edit loop. It reads a
382
+ Claude Code hook event on stdin and dispatches by `hook_event_name`:
383
+
384
+ - **PostToolUse** on an Edit/Write re-lints the edited file (Go: its
385
+ package). On a finding it exits 2 with the finding on stderr, so the
386
+ model sees it and fixes it in-loop. On a **Bash** command it instead
387
+ diffs the whole worktree against HEAD and blocks if the command
388
+ planted a new suppression marker (on a lintable file) or a new
389
+ `.tackbox-reporters` line - containment for a marker a shell wrote
390
+ behind the Edit gate. Stateless: HEAD is the approval record, so an
391
+ approved marker stops asking once committed (worst case, a repeated
392
+ question, never a silent pass). The authoritative gate stays
393
+ pre-commit / CI.
394
+ - **PreToolUse** asks for approval before a new suppression marker
395
+ (`// no-report`, `// parse-skip`, `// nil-return`, `// test-skip`,
396
+ `// dup-ok`) or a new `.tackbox-reporters` line lands;
397
+ removing one is free.
398
+
399
+ Both marker gates ask only about files an engine would lint (D012): a
400
+ marker in a Go `testdata/` path or a non-lintable fixture extension
401
+ (a `.java.txt`) is dead text and draws no question, while the
402
+ `.tackbox-reporters` gate stays unconditional.
403
+
404
+ The hook is a no-op unless the edit's `cwd` is a git repo with a
405
+ `dev.py` at its root. Wire it once, globally, in
406
+ `~/.claude/settings.json`:
407
+
408
+ ```json
409
+ {
410
+ "hooks": {
411
+ "PreToolUse": [
412
+ {"matcher": "Edit|Write|MultiEdit",
413
+ "hooks": [{"type": "command", "command": "uvx tackbox hook"}]}
414
+ ],
415
+ "PostToolUse": [
416
+ {"matcher": "Edit|Write|MultiEdit|Bash",
417
+ "hooks": [{"type": "command", "command": "uvx tackbox hook", "timeout": 120}]}
418
+ ]
419
+ }
420
+ }
421
+ ```
422
+
423
+ `uvx tackbox hook` runs the cached tackbox (no `@latest`): the hook is
424
+ fast in-loop feedback, not the authoritative gate.
425
+
426
+ ## Escapes inventory
427
+
428
+ `tackbox escapes` prints the repo's whole bypass surface as JSON on
429
+ stdout - every place code legitimately steps off the paved road, in one
430
+ cheap command that review tooling of any harness can consume (D013). It
431
+ enumerates:
432
+
433
+ - **suppression markers** (`// no-report`, `// parse-skip`,
434
+ `// nil-return`, `// long-comment`, `// test-skip`, `// dup-ok`, plus
435
+ the markdown `tackbox: lang=` marker), each with its reason;
436
+ - **`.tackbox-reporters` declarations** - the tier-2 sinks;
437
+ - **notify / quiet lane choices** - the call sites of the user-lane-only
438
+ `notify` and the telemetry-only `quiet` verbs.
439
+
440
+ It is an **inventory, not a gate**: it exits 0 whenever it runs, entries
441
+ or not, and is not wired into `dev.py check`. The rules and the hook are
442
+ the enforcement; this command is food for a reviewer (human or agent)
443
+ who wants the escapes laid out without re-deriving them. Exit is nonzero
444
+ (1, one stderr line) only for an infra error - a bad `--since` rev.
445
+
446
+ ```bash
447
+ uvx tackbox@latest escapes
448
+ uvx tackbox@latest escapes --since origin/main --context 5
449
+ ```
450
+
451
+ ### JSON contract
452
+
453
+ ```json
454
+ {
455
+ "version": 1,
456
+ "since": null,
457
+ "entries": [
458
+ {"kind": "marker", "file": "a/b.py", "line": 12,
459
+ "text": "no-report: central boundary already captures it",
460
+ "reason": "central boundary already captures it",
461
+ "context": ["...", "...", "..."]},
462
+ {"kind": "reporter-decl", "file": ".tackbox-reporters", "line": 2,
463
+ "text": "src/app/errors.py#report_api_error: the API sink",
464
+ "context": ["..."]},
465
+ {"kind": "notify-site", "file": "js/foo.js", "line": 40,
466
+ "text": "notify('offline', err, {}, 'net.offline')",
467
+ "context": ["..."]},
468
+ {"kind": "quiet-site", "file": "go/x.go", "line": 9,
469
+ "text": "report.Quiet(ctx, ...)", "context": ["..."]}
470
+ ],
471
+ "counts": {"marker": 1, "reporter-decl": 1, "notify-site": 1, "quiet-site": 1}
472
+ }
473
+ ```
474
+
475
+ - `version` is the schema version (`1`); `counts` always carries all four
476
+ kinds, even at zero, so consumers see a stable shape.
477
+ - `since` echoes the `--since` rev, or `null`.
478
+ - `text` is the trimmed source line; for a marker it runs from the marker
479
+ keyword to end of line (the hook's own `_markers` extraction).
480
+ - `reason` (markers only) is what follows the keyword's colon, trimmed -
481
+ possibly empty (the `tackbox: lang=` marker carries none).
482
+ - `context` is the surrounding source, `--context N` lines each side
483
+ (default 3), inclusive of the entry line itself - the window
484
+ `[line-N, line+N]`, clipped at file edges, each line trimmed of trailing
485
+ whitespace. It is plain source; the entry line is not marked.
486
+ - `entries` are sorted by `(file, line)` for stable output.
487
+
488
+ ### Scope and detection
489
+
490
+ The scan covers the same lintable source set the linter would scan (the
491
+ D012 predicate: extension match plus each engine's path filter, so a Go
492
+ `testdata/` file is out), plus the root `.tackbox-reporters` (every
493
+ non-empty line is one declaration - the file has no comment syntax).
494
+ notify / quiet call sites are detected **textually per language**
495
+ (`report_quiet` / `notify` in Python, `reportQuiet` / `notify` in the JS
496
+ family, `.Quiet(` / `.Notify(` in Go, `.quiet(` / `.notify(` in Java),
497
+ word-boundaried so `notifyAll(` does not match. Textual detection can
498
+ over-report (a match inside a comment or string counts) - that is fine:
499
+ this is observability, not a lint.
500
+
501
+ ### `--since <rev>`
502
+
503
+ `--since <rev>` prints only entries **new against `<rev>`**, compared by
504
+ content identity `(kind, file, text)` - the same extraction run against
505
+ the tree at `<rev>` (via `git ls-tree` + `git show`) subtracted, count
506
+ aware, from the current tree's entries. It over-reports on moved code (a
507
+ new file path is a new identity) but never silently drops an entry - the
508
+ conservative direction for a review aid. A bad rev is the one infra error:
509
+ one stderr line, exit 1.
510
+
511
+ ## Layout
512
+
513
+ ```text
514
+ dev.py # lint / test / e2e / check (dev-script)
515
+ hygiene.py # dev.py lint hygiene (conflict/yaml/ws/newline)
516
+ go.mod # Go module
517
+ package.json # npm package (ESLint plugin + report helper)
518
+ eslint.config.preset.js # default config used by tackbox-eslint bin
519
+ bin/tackbox-eslint.js # ESLint CLI wrapper with bundled preset
520
+ bin/tackbox-mdlint.js # markdownlint wrapper with bundled preset
521
+ go/
522
+ cmd/erclint/ # native Go analyzers (ERC001-009)
523
+ cmd/erclint-opengrep/ # opengrep wrapper, embedded rule yamls
524
+ rules/ # exceptions-go (go-exit-in-recover)
525
+ analyzers/ # per-rule go/analysis packages
526
+ internal/ # markers + AST helpers
527
+ report/ # Go capture helper (Sentry/glitchtip)
528
+ java/
529
+ pom.xml # Maven module -> shaded javalint.jar
530
+ src/main/.../javalint/ # typed-AST analyzer (JV001-010)
531
+ rules/ # per-rule checkers
532
+ report/ # Java capture helper -> Maven Central io.github.nikitatsym:report
533
+ js/
534
+ eslint-plugin.js # ESLint plugin entry
535
+ rules/ # 14 frontend rules
536
+ markdownlint-rules/ # custom markdownlint rules
537
+ report.js # browser capture helper (@sentry/browser)
538
+ tests/ # RuleTester + node:test
539
+ py/
540
+ tackbox/ # lint / hook / doctor CLI, cache, engines
541
+ pyrules/ # flake8 TBX plugin (python exception rules)
542
+ tackbox_report/ # Python capture helper -> PyPI tackbox-report
543
+ tests/ # pytest suite
544
+ docs/
545
+ publishing-helpers.md # helper release runbook (PyPI + Maven Central)
546
+ ```
547
+
548
+ ## Repo conventions
549
+
550
+ - Versioned via git tags (`vMAJOR.MINOR.PATCH`); CI auto-bumps the
551
+ patch tag on every green push to `main` and publishes the wheels.
552
+ Consumers track `@latest`, never a pinned version.