tackbox 0.1.97 → 0.1.98

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
@@ -163,12 +163,12 @@ past (argparse misuse, and the per-command cases below).
163
163
  `--draft` is a generator, not a gate: `0` when every uncovered
164
164
  marker was drafted (an orphan-only tree included), `2` only when
165
165
  unresolvable files leave the draft incomplete.
166
- - **hook** - `0` a no-op, a clean event, or a JSON decision (a
167
- PreToolUse approval prompt or a PostToolUse Bash block); `1` a
166
+ - **hook** - `0` a no-op, a clean event, or a JSON pre decision (an
167
+ approval prompt or a refusal before an unrelated edit); `1` a
168
168
  non-blocking infra error (unreadable stdin, a git failure); `2` a
169
- PostToolUse finding on the edited lines, a non-compiling Go
170
- package, or an approvals inconsistency anywhere in the worktree,
171
- which blocks the edit in-loop.
169
+ PostToolUse finding on edited lines, a non-compiling Go package, or
170
+ session approvals debt in a touched file. A post error reports a
171
+ mutation that already landed; it does not block or undo the write.
172
172
  - **hook-protocol** - `0` whenever a decision was reached, whatever the
173
173
  decision says (it rides the JSON on stdout, never the exit code); `1`
174
174
  plus one stderr line when none was: unreadable stdin, or a request
@@ -473,8 +473,8 @@ scopes (lambdas, arrows, IIFEs) appear as 8-hex content hashes; Java
473
473
  overloads carry a parameter-type signature; same-name siblings take
474
474
  an `@k` ordinal. Repeat the line for each identical occurrence.
475
475
 
476
- The check is bidirectional and always covers the whole tree: a
477
- marker without a covering entry and an entry without a live marker
476
+ For `tackbox lint` and `tackbox approvals`, the bidirectional check covers the whole tree:
477
+ a marker without a covering entry and an entry without a live marker
478
478
  (an orphan) are both findings, reported by `tackbox lint` under the
479
479
  `approvals (whole tree):` header whatever the lint scope.
480
480
  `tackbox approvals` runs the same check standalone;
@@ -488,11 +488,19 @@ Approving is adding the line. In an agent session the edit that adds
488
488
  a manifest line draws the PreToolUse ask quoting the entry (several
489
489
  lines in one edit draw one all-or-nothing ask), so the only route to
490
490
  a green check passes through a visible diff and a human decision.
491
- Writing a marker itself never asks - by any channel, Edit or shell -
492
- it merely leaves the tree inconsistent, which every later hook
493
- event, `dev.py check`, and CI reports until the entry lands or the
494
- marker is reverted. Removing a manifest line is free; a marker whose
495
- text, scope, or count changes needs its entry updated the same way.
491
+ Writing a marker itself never asks - by any channel, Edit or shell.
492
+ New session debt blocks the next edit outside the affected marker files
493
+ and `.tackbox/approvals`; post-edit feedback reports debt only in touched
494
+ files. Bash and eval run no approvals checks. `dev.py check` and CI still
495
+ report all tree inconsistencies. Removing a manifest line is free; a
496
+ marker whose text, scope, or count changes needs its entry updated.
497
+
498
+ Approvals messages carry the suppressed rule's text, or say the marker
499
+ suppresses nothing when no finding is available, such as in the standalone
500
+ `approvals` command. Each inconsistency takes one line; no message supplies
501
+ an approval entry to copy. Suppressed findings stay out of ordinary lint
502
+ output, its exit status, and codequality. See D020 in
503
+ [rules/DECISIONS.md](rules/DECISIONS.md) for the session boundary.
496
504
 
497
505
  ## Generated and vendored code
498
506
 
@@ -575,15 +583,18 @@ specified in [docs/report-contracts.md](docs/report-contracts.md).
575
583
 
576
584
  ## Agent hook
577
585
 
578
- The rules wire into a coding agent's edit loop through one shared core: the
579
- approval gates, the diff-scoped lint, and the whole-tree consistency check
580
- are the same whichever host drives them. In host-neutral terms:
581
-
582
- - **Post-edit** re-lints the touched files (Go: their package). A finding on
583
- the lines the edit added blocks with the finding text. Every post event -
584
- including an opaque channel - also runs the whole-tree approvals consistency
585
- check: an unapproved marker, an orphaned entry, or an unresolvable file blocks
586
- with the named fix. A verified violation is always a tool error. A post event
586
+ The rules wire into a coding agent's edit loop through one shared core:
587
+ approval gates, diff-scoped lint, and session approvals debt have the same
588
+ semantics whichever host drives them. Session means the worktree difference
589
+ from HEAD, including staged changes and untracked files; without a commit,
590
+ the whole tree is added. Existing debt is left to `dev.py check` and CI.
591
+
592
+ - **Post-edit** re-lints touched files (Go: their package). A finding on
593
+ added lines or session approvals debt in a touched file becomes a tool
594
+ error carrying the findings. The mutation has already landed; this is
595
+ not a block or rollback. Debt includes a new uncovered marker, a new
596
+ orphaned approval, and an approval whose marker was deleted.
597
+ A verified violation is always a tool error. A post event
587
598
  that cannot be verified reports three facts: the mutation may already have
588
599
  landed, why verification did not complete, and that the mutation must not be
589
600
  repeated before `dev.py check`. OMP appends that warning to the model-facing
@@ -596,6 +607,10 @@ are the same whichever host drives them. In host-neutral terms:
596
607
  A known target whose content is ambiguous asks when it reaches a bypass
597
608
  surface. An unclassifiable file mutation or a failed policy dependency blocks
598
609
  before it can run; it is never weakened into an approval prompt.
610
+ Session approvals debt also blocks an edit if any target is outside the
611
+ repair set: files of the debt's markers and `.tackbox/approvals`.
612
+ A multi-file edit with one unrelated target is refused as a whole.
613
+ The existing approval gates still apply to every permitted repair.
599
614
 
600
615
  Only markers in files an engine would lint participate in the check
601
616
  (D012): a marker in a Go `testdata/` path or a non-lintable fixture
@@ -658,17 +673,17 @@ the current file.
658
673
  three-fact warning, omits an `isError` override so OMP preserves the host
659
674
  state, and tells the model not to repeat the mutation before `dev.py check`.
660
675
  - an opaque write channel (`xd://` tool devices, archive members, SQLite rows),
661
- every `bash` call, and every `eval` call name no file, so they run the
662
- whole-tree approvals wall alone.
676
+ every `bash` call, and every `eval` call name no file, so they run
677
+ neither approvals checks nor targeted lint.
663
678
  - MCP tool names are not enumerated by this extension. Their file mutations are
664
- an explicit residual outside its pre gate and post wall; review their diff and
665
- run `dev.py check`.
679
+ an explicit residual outside its pre gate and targeted post checks;
680
+ review their diff and run `dev.py check`.
666
681
  - the post adapter consumes each result-detail record independently. It falls
667
682
  back to a snapshot only for that record when the record is pruned; failed
668
683
  records do not widen the scope of successful landed records. OMP 18.x does
669
684
  not identify a landed subset for a single aggregate error without per-file
670
- details, so Tackbox runs its whole-tree wall, preserves the host error, and
671
- cannot safely perform targeted lint for that residual.
685
+ details, so Tackbox preserves the host error and cannot safely perform
686
+ targeted lint for that residual. It does not run a whole-tree approvals check.
672
687
 
673
688
  The extension runs `uvx tackbox@<npm package version> hook-protocol`. A tagged
674
689
  wheel is built and protocol-canary tested, published to PyPI, then a successful
@@ -711,16 +726,16 @@ one JSON decision on stdout:
711
726
  landed). Pre requests omit `succeeded`; post requests require the boolean
712
727
  `succeeded`, so a failed tool is not misreported as a missing landed file.
713
728
  - `tool` is one of `edit`, `apply_patch`, `write`, `bash`, or `eval`. `bash` and
714
- `eval` are target-free wall-only channels.
729
+ `eval` are target-free channels and run no approvals or targeted lint.
715
730
  - a **target** is one file mutation with an absolute `path`, `op`, and
716
731
  `expectedPresent`. `edit` and `write` expect the path to exist, `delete`
717
732
  expects it absent, and a move reports an absent source plus a present
718
733
  destination. `content` is a full replacement; otherwise `added` and
719
734
  `removed` are text fragments. `content` and fragments are mutually exclusive.
720
735
  `ambiguous: true` means a known target needs whole-file treatment.
721
- - **zero targets** is the opaque channel: the whole-tree wall runs, nothing
722
- file-scoped does. `unknown` is a non-empty reason only when no concrete
723
- target can be named; it blocks pre and warns post.
736
+ - **zero targets** is the opaque channel: no approvals or targeted lint runs.
737
+ `unknown` is a non-empty reason only when no concrete target can be named;
738
+ it blocks pre and warns post.
724
739
  - the wire decisions are `allow`, `ask`, `block`, and `warn`. The semantic
725
740
  outcomes are inactive, allow, approval-required, violation, and unverified:
726
741
  unverified maps to `block` pre and `warn` post. Hosts must make a post warning
@@ -2,6 +2,7 @@
2
2
  const path = require('path')
3
3
  const fs = require('fs')
4
4
  const { ESLint } = require('eslint')
5
+ const { setSuppressedReporter } = require('../js/rules/_shared')
5
6
 
6
7
  const REPORTERS_FLAG = '--reporters='
7
8
 
@@ -129,6 +130,12 @@ async function main() {
129
130
  process.stderr.write('tackbox-eslint: no files supplied\n')
130
131
  process.exit(2)
131
132
  }
133
+ if (machine) {
134
+ setSuppressedReporter(finding => {
135
+ const file = path.relative(process.cwd(), finding.file).split(path.sep).join('/')
136
+ process.stdout.write(JSON.stringify({ ...finding, file }) + '\n')
137
+ })
138
+ }
132
139
  validateDeclarations(decls)
133
140
  const eslint = new ESLint({
134
141
  // Inline directives (eslint-disable ... tackbox/<rule>) would be an
package/js/README.md CHANGED
@@ -146,6 +146,13 @@ siblings still report. Recognition reads the enclosing element's
146
146
  preceding `SvelteHTMLComment` sibling; `/* ... */` block comments
147
147
  are never markers.
148
148
 
149
+ The wrapper's `--machine` output retains suppressed findings with the
150
+ same rule text plus `suppressed`, `marker_kind`, and `marker_line`.
151
+ Human ESLint output and its exit status ignore them. Only the shared
152
+ approvals renderer presents them (D020 in
153
+ [../rules/DECISIONS.md](../rules/DECISIONS.md)); a marker that excuses
154
+ no violation produces no suppressed finding.
155
+
149
156
  ## Markdown
150
157
 
151
158
  The CLI also lints `.md` files through `tackbox-mdlint`, a thin markdownlint
package/js/omp/index.js CHANGED
@@ -9,7 +9,7 @@ const HEADLESS_NOTE =
9
9
  'tackbox cannot ask here (no interactive session), so the call is blocked' +
10
10
  ' instead of approved. Re-issue it interactively, or drop the gated line.'
11
11
  const DENIED = 'tackbox: approval denied.'
12
- const BLOCKED = 'tackbox blocked this change:'
12
+ const FINDINGS = 'tackbox found issues after this change:'
13
13
 
14
14
  module.exports = function tackbox(pi) {
15
15
  pi.setLabel('tackbox')
@@ -61,7 +61,7 @@ function applyPost(decision, event) {
61
61
  const kind = decision && decision.kind
62
62
  if (kind === hook.BLOCK) {
63
63
  return {
64
- content: appended(event, `${BLOCKED}\n${decision.reason}`),
64
+ content: appended(event, `${FINDINGS}\n${decision.reason}`),
65
65
  isError: true,
66
66
  }
67
67
  }
@@ -392,12 +392,18 @@ function blockHasReport(context, block, errName) {
392
392
  return found
393
393
  }
394
394
 
395
- // markerText reports whether a comment's raw text is `<prefix>: <reason>` with
396
- // reason at least MIN_REASON chars (D009) - the shared marker shape.
397
- function markerText(raw, prefix) {
395
+ // Multiline HTML comments locate approval identity at the keyword, not the delimiter.
396
+ function markerLine(comment, prefix) {
397
+ const raw = comment.value
398
398
  const text = raw.trim()
399
- if (!text.startsWith(prefix + ':')) return false
400
- return text.slice(prefix.length + 1).trim().length >= MIN_REASON
399
+ const marker = prefix + ':'
400
+ if (!text.startsWith(marker) || text.slice(marker.length).trim().length < MIN_REASON) return null
401
+ let line = comment.loc.start.line
402
+ const offset = raw.indexOf(marker)
403
+ for (let i = 0; i < offset; i++) {
404
+ if (raw[i] === '\n') line++
405
+ }
406
+ return line
401
407
  }
402
408
 
403
409
  // precedingSvelteSibling returns the template node immediately before `el` among
@@ -416,30 +422,51 @@ function precedingSvelteSibling(el) {
416
422
  return null
417
423
  }
418
424
 
419
- // hasMarkerAbove returns true when a suppression marker `<prefix>: <reason>`
420
- // (reason at least MIN_REASON chars, D009) sits above node. Two forms: a `//`
421
- // comment block directly above node - any of its contiguous lines, so a long
422
- // reason can be followed by human context, a blank line breaking the block - and,
423
- // in a Svelte template, an HTML comment `<!-- ... -->` immediately above an
424
- // enclosing element, which covers the whole element (residual A8: an inline
425
- // handler can span lines). getAllComments omits SvelteHTMLComment nodes, so the
426
- // template form is read off the element's preceding sibling.
427
- function hasMarkerAbove(context, node, prefix) {
428
- if (!node || !node.loc) return false
425
+ // Svelte HTML comments cover the following element, not just its first line.
426
+ function markerAbove(context, node, prefix) {
427
+ if (!node || !node.loc) return null
429
428
  const sourceCode = context.sourceCode || context.getSourceCode()
430
429
  const byEndLine = new Map()
431
430
  for (const c of sourceCode.getAllComments()) {
432
431
  if (c.type === 'Line') byEndLine.set(c.loc.end.line, c)
433
432
  }
434
433
  for (let line = node.loc.start.line - 1; byEndLine.has(line); line--) {
435
- if (markerText(byEndLine.get(line).value, prefix)) return true
434
+ const comment = byEndLine.get(line)
435
+ const marker = markerLine(comment, prefix)
436
+ if (marker !== null) return marker
436
437
  }
437
438
  for (let cur = node.parent; cur; cur = cur.parent) {
438
439
  if (cur.type !== 'SvelteElement') continue
439
440
  const sib = precedingSvelteSibling(cur)
440
- if (sib && sib.type === 'SvelteHTMLComment' && markerText(sib.value, prefix)) return true
441
+ if (sib && sib.type === 'SvelteHTMLComment') {
442
+ const marker = markerLine(sib, prefix)
443
+ if (marker !== null) return marker
444
+ }
445
+ }
446
+ return null
447
+ }
448
+
449
+ let suppressedReporter = null
450
+
451
+ function setSuppressedReporter(reporter) {
452
+ suppressedReporter = reporter
453
+ }
454
+
455
+ function reportWithMarker(context, descriptor, anchor, kind, messages) {
456
+ const line = markerAbove(context, anchor, kind)
457
+ if (line === null) {
458
+ context.report(descriptor)
459
+ } else if (suppressedReporter) {
460
+ suppressedReporter({
461
+ file: context.filename || context.getFilename(),
462
+ line: descriptor.node.loc.start.line,
463
+ rule: context.id,
464
+ message: messages[descriptor.messageId],
465
+ suppressed: true,
466
+ marker_kind: kind,
467
+ marker_line: line,
468
+ })
441
469
  }
442
- return false
443
470
  }
444
471
 
445
472
  // --- F2b: path-sensitive no-swallow analysis -----------------------------
@@ -835,7 +862,8 @@ module.exports = {
835
862
  walk,
836
863
  blockHasThrow,
837
864
  blockHasReport,
838
- hasMarkerAbove,
865
+ reportWithMarker,
866
+ setSuppressedReporter,
839
867
  enclosingFn,
840
868
  fnReturnsResultLike,
841
869
  someNode,
@@ -1,4 +1,4 @@
1
- const { hasMarkerAbove, walk, isTier1Notify, argFlows, isTestFile } = require('./_shared')
1
+ const { reportWithMarker, walk, isTier1Notify, argFlows, isTestFile } = require('./_shared')
2
2
 
3
3
  // guarded: `call` sits under an additional condition strictly inside the catch
4
4
  // body - an if-branch (consequent/alternate) or a switch case. A notify
@@ -35,11 +35,12 @@ module.exports = {
35
35
  if (errName == null) return
36
36
  const body = node.body
37
37
  if (!body || body.type !== 'BlockStatement') return
38
- if (hasMarkerAbove(context, node.parent, 'no-report')) return
39
38
  walk(body, call => {
40
39
  if (call.type !== 'CallExpression') return
41
40
  if (!isTier1Notify(context, call) || !argFlows(call, errName)) return
42
- if (!guarded(call, body)) context.report({ node: call, messageId: 'broad' })
41
+ if (!guarded(call, body)) {
42
+ reportWithMarker(context, { node: call, messageId: 'broad' }, node.parent, 'no-report', module.exports.meta.messages)
43
+ }
43
44
  })
44
45
  },
45
46
  }
@@ -1,5 +1,5 @@
1
1
  const {
2
- hasMarkerAbove,
2
+ reportWithMarker,
3
3
  enclosingFn,
4
4
  fnReturnsResultLike,
5
5
  errObjectFlows,
@@ -139,11 +139,10 @@ module.exports = {
139
139
  if (!tryBlockParses(node.block)) return
140
140
  const handler = node.handler
141
141
  if (!handler || !handler.body || handler.body.type !== 'BlockStatement') return
142
- if (hasMarkerAbove(context, node, 'parse-skip')) return
143
142
  const errName = handler.param && handler.param.type === 'Identifier' ? handler.param.name : null
144
143
  const allowBoundary = fnReturnsResultLike(enclosingFn(node))
145
144
  if (catchPropagates(handler.body, errName, allowBoundary)) return
146
- context.report({ node: handler, messageId: 'fallback' })
145
+ reportWithMarker(context, { node: handler, messageId: 'fallback' }, node, 'parse-skip', module.exports.meta.messages)
147
146
  },
148
147
  }
149
148
  },
@@ -1,4 +1,4 @@
1
- const { hasMarkerAbove, matchesTestModifier, isStaticString, staticStringValue, TEST_ROOTS } = require('./_shared')
1
+ const { reportWithMarker, matchesTestModifier, isStaticString, staticStringValue, TEST_ROOTS } = require('./_shared')
2
2
 
3
3
  const SKIP_PROPS = new Set(['skip', 'todo', 'skipIf', 'fixme'])
4
4
  const BARE = new Set(['xit', 'xdescribe', 'xtest'])
@@ -80,14 +80,12 @@ module.exports = {
80
80
  CallExpression(node) {
81
81
  if (matchesTestModifier(node.callee, BARE, n => SKIP_PROPS.has(n))) {
82
82
  if (hasInCallReason(node)) return
83
- if (hasMarkerAbove(context, outermostCall(node), 'test-skip')) return
84
- context.report({ node, messageId: 'skipped' })
83
+ reportWithMarker(context, { node, messageId: 'skipped' }, outermostCall(node), 'test-skip', module.exports.meta.messages)
85
84
  return
86
85
  }
87
86
  if (node.callee.type === 'Identifier' && TEST_ROOTS.has(node.callee.name)) {
88
87
  if (optionsSkipVerdict(node) !== 'flag') return
89
- if (hasMarkerAbove(context, outermostCall(node), 'test-skip')) return
90
- context.report({ node, messageId: 'skipped' })
88
+ reportWithMarker(context, { node, messageId: 'skipped' }, outermostCall(node), 'test-skip', module.exports.meta.messages)
91
89
  }
92
90
  },
93
91
  }
@@ -1,4 +1,4 @@
1
- const { hasMarkerAbove, enclosingFn, someNode } = require('./_shared')
1
+ const { reportWithMarker, enclosingFn, someNode } = require('./_shared')
2
2
 
3
3
  // isAllSettledCall: syntactic `Promise.allSettled(...)`. Matched by shape, not
4
4
  // resolution - Promise is a global and allSettled is unambiguous.
@@ -43,9 +43,8 @@ module.exports = {
43
43
  return {
44
44
  CallExpression(node) {
45
45
  if (!isAllSettledCall(node)) return
46
- if (hasMarkerAbove(context, node, 'no-report')) return
47
46
  if (refsReason(enclosingFn(node) || sc.ast)) return
48
- context.report({ node, messageId: 'swallow' })
47
+ reportWithMarker(context, { node, messageId: 'swallow' }, node, 'no-report', module.exports.meta.messages)
49
48
  },
50
49
  }
51
50
  },
@@ -1,5 +1,5 @@
1
1
  const {
2
- hasMarkerAbove,
2
+ reportWithMarker,
3
3
  enclosingFn,
4
4
  fnReturnsResultLike,
5
5
  makeHandledAnalysis,
@@ -20,11 +20,10 @@ module.exports = {
20
20
  const body = node.body
21
21
  if (!body || body.type !== 'BlockStatement') return
22
22
  const tryStmt = node.parent
23
- if (tryStmt && hasMarkerAbove(context, tryStmt, 'no-report')) return
24
23
  const errName = node.param && node.param.type === 'Identifier' ? node.param.name : null
25
24
  const allowBoundary = fnReturnsResultLike(enclosingFn(node))
26
25
  if (makeHandledAnalysis({ context, errName, allowBoundary }).handled(body)) return
27
- context.report({ node, messageId: 'swallow' })
26
+ reportWithMarker(context, { node, messageId: 'swallow' }, tryStmt, 'no-report', module.exports.meta.messages)
28
27
  },
29
28
  }
30
29
  },
@@ -1,4 +1,4 @@
1
- const { hasMarkerAbove, makeHandledAnalysis } = require('./_shared')
1
+ const { reportWithMarker, makeHandledAnalysis } = require('./_shared')
2
2
 
3
3
  // rejectionHandler returns the rejection-handler argument of a promise
4
4
  // method: `.catch(onErr)` -> arg 0, `.then(onOk, onErr)` -> arg 1. A single-arg
@@ -28,10 +28,9 @@ module.exports = {
28
28
  const handler = rejectionHandler(node)
29
29
  if (!handler) return
30
30
  if (handler.type !== 'ArrowFunctionExpression' && handler.type !== 'FunctionExpression') return
31
- if (hasMarkerAbove(context, node, 'no-report')) return
32
31
  const errName = handler.params[0] && handler.params[0].type === 'Identifier' ? handler.params[0].name : null
33
32
  if (makeHandledAnalysis({ context, errName, allowBoundary: false, returnIdentity: true }).handled(handler.body)) return
34
- context.report({ node, messageId: 'swallow' })
33
+ reportWithMarker(context, { node, messageId: 'swallow' }, node, 'no-report', module.exports.meta.messages)
35
34
  },
36
35
  }
37
36
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tackbox",
3
- "version": "0.1.97",
3
+ "version": "0.1.98",
4
4
  "description": "ESLint and Markdown lint plugins, an Oh My Pi extension, plus direct error-reporting helpers for JavaScript and TypeScript.",
5
5
  "license": "MIT",
6
6
  "main": "./js/eslint-plugin.js",