tackbox 0.1.83 → 0.1.89
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 +136 -23
- package/js/README.md +8 -0
- package/js/omp/hook.js +213 -0
- package/js/omp/index.js +118 -0
- package/js/omp/index.mjs +3 -0
- package/js/omp/payload.js +749 -0
- package/package.json +11 -2
package/README.md
CHANGED
|
@@ -169,6 +169,10 @@ past (argparse misuse, and the per-command cases below).
|
|
|
169
169
|
PostToolUse finding on the edited lines, a non-compiling Go
|
|
170
170
|
package, or an approvals inconsistency anywhere in the worktree,
|
|
171
171
|
which blocks the edit in-loop.
|
|
172
|
+
- **hook-protocol** - `0` whenever a decision was reached, whatever the
|
|
173
|
+
decision says (it rides the JSON on stdout, never the exit code); `1`
|
|
174
|
+
plus one stderr line when none was: unreadable stdin, or a request
|
|
175
|
+
whose protocol version this tackbox does not speak.
|
|
172
176
|
- **escapes** - `0` whenever it runs, entries or not (an inventory,
|
|
173
177
|
not a gate); `1` only for a bad `--since` rev.
|
|
174
178
|
|
|
@@ -569,34 +573,44 @@ specified in [docs/report-contracts.md](docs/report-contracts.md).
|
|
|
569
573
|
- [Python](py/tackbox_report/README.md)
|
|
570
574
|
- [Java](java/report/README.md)
|
|
571
575
|
|
|
572
|
-
## Agent hook
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
the
|
|
580
|
-
|
|
581
|
-
an unapproved marker, an orphaned entry, or an unresolvable file
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
the
|
|
576
|
+
## Agent hook
|
|
577
|
+
|
|
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
|
|
587
|
+
that cannot be verified reports three facts: the mutation may already have
|
|
588
|
+
landed, why verification did not complete, and that the mutation must not be
|
|
589
|
+
repeated before `dev.py check`. OMP appends that warning to the model-facing
|
|
590
|
+
tool result without changing a successful tool state. Claude Code writes it
|
|
591
|
+
to user-visible PostToolUse stderr with exit 1, so it is not model-visible.
|
|
592
|
+
- **Pre-edit** asks for approval before a new `.tackbox/approvals` line or a
|
|
593
|
+
new `.tackbox/reporters` line lands, before a positive exclusion line is
|
|
594
|
+
added to any `.gitattributes`, before editing an attribute-excluded file, and
|
|
595
|
+
before deleting or moving the root `dev.py`; removing a gate line is free.
|
|
596
|
+
A known target whose content is ambiguous asks when it reaches a bypass
|
|
597
|
+
surface. An unclassifiable file mutation or a failed policy dependency blocks
|
|
598
|
+
before it can run; it is never weakened into an approval prompt.
|
|
592
599
|
|
|
593
600
|
Only markers in files an engine would lint participate in the check
|
|
594
601
|
(D012): a marker in a Go `testdata/` path or a non-lintable fixture
|
|
595
602
|
extension (a `.java.txt`) is dead text - no entry needed, no
|
|
596
603
|
question - while the `.tackbox/reporters` gate stays unconditional.
|
|
597
604
|
|
|
598
|
-
The hook is
|
|
599
|
-
`
|
|
605
|
+
The hook is inactive only when `git rev-parse --show-toplevel` emits C-locale
|
|
606
|
+
stderr containing `not a git repository`, or after its discovered root has no
|
|
607
|
+
`dev.py`. A missing git executable, corrupt Git config, or another discovery
|
|
608
|
+
failure is unverified, not a no-op.
|
|
609
|
+
|
|
610
|
+
### Claude Code
|
|
611
|
+
|
|
612
|
+
`tackbox hook` reads a Claude Code hook event on stdin and dispatches by
|
|
613
|
+
`hook_event_name` (`PreToolUse`, `PostToolUse`). Wire it once, globally, in
|
|
600
614
|
`~/.claude/settings.json`:
|
|
601
615
|
|
|
602
616
|
```json
|
|
@@ -617,6 +631,104 @@ The hook is a no-op unless the edit's `cwd` is a git repo with a
|
|
|
617
631
|
`uvx tackbox hook` runs the cached tackbox (no `@latest`): the hook is
|
|
618
632
|
fast in-loop feedback, not the authoritative gate.
|
|
619
633
|
|
|
634
|
+
### Oh My Pi
|
|
635
|
+
|
|
636
|
+
```bash
|
|
637
|
+
omp plugin install tackbox
|
|
638
|
+
```
|
|
639
|
+
OMP loads the ESM entry point `js/omp/index.mjs`, which delegates to the internal
|
|
640
|
+
CommonJS implementation.
|
|
641
|
+
|
|
642
|
+
That is the whole wiring: the npm package declares an extension
|
|
643
|
+
(`package.json#omp.extensions`) and OMP loads it. The extension subscribes to
|
|
644
|
+
the public `tool_call` / `tool_result` events and covers all five OMP 18.x
|
|
645
|
+
`edit` modes: `replace`, `patch`, `hashline`, `apply_patch`, and `sloppy`,
|
|
646
|
+
including multi-file edits, clipboard registers, moves, and deletes. Its
|
|
647
|
+
compatibility parser accepts `U+00B6PATH#TAG` headers plus sloppy `[path]`,
|
|
648
|
+
`U+00A7path`, and `U+00A7*path` section openers; bare `U+00A7` forms continue
|
|
649
|
+
the current file.
|
|
650
|
+
|
|
651
|
+
- a pre-edit ask becomes a confirmation dialog. In a headless or subagent
|
|
652
|
+
session, where nobody can answer, it blocks with the reason instead of
|
|
653
|
+
approving itself; a denied ask blocks before the tool runs.
|
|
654
|
+
- a verified post violation becomes a tool error carrying the findings, which
|
|
655
|
+
keeps the agent in-loop on them.
|
|
656
|
+
- an unverified pre event blocks. The child has a 20-second deadline inside
|
|
657
|
+
OMP's 30-second handler budget. An unverified post event carries the shared
|
|
658
|
+
three-fact warning, omits an `isError` override so OMP preserves the host
|
|
659
|
+
state, and tells the model not to repeat the mutation before `dev.py check`.
|
|
660
|
+
- 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.
|
|
663
|
+
- 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`.
|
|
666
|
+
- the post adapter consumes each result-detail record independently. It falls
|
|
667
|
+
back to a snapshot only for that record when the record is pruned; failed
|
|
668
|
+
records do not widen the scope of successful landed records. OMP 18.x does
|
|
669
|
+
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.
|
|
672
|
+
|
|
673
|
+
The extension runs `uvx tackbox@<npm package version> hook-protocol`. A tagged
|
|
674
|
+
wheel is built and protocol-canary tested, published to PyPI, then a successful
|
|
675
|
+
release workflow automatically publishes the matching npm package from its
|
|
676
|
+
immutable completed-run source. If a pending npm job is canceled, rerun `publish`
|
|
677
|
+
from the Actions UI; there is no standalone npm redispatch.
|
|
678
|
+
|
|
679
|
+
For development against a working tree, name the command explicitly - a JSON array
|
|
680
|
+
of argv, never a shell string:
|
|
681
|
+
|
|
682
|
+
```bash
|
|
683
|
+
TACKBOX_OMP_COMMAND='["uv","run","--directory","py","python","-m","tackbox.cli"]'
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
The subcommand is appended by the extension, never taken from the override, so
|
|
687
|
+
a development command cannot answer a different protocol. There is no
|
|
688
|
+
`@latest` fallback: an unpinned wheel could answer a protocol version the
|
|
689
|
+
extension does not speak.
|
|
690
|
+
|
|
691
|
+
### Other hosts
|
|
692
|
+
|
|
693
|
+
`tackbox hook-protocol` is the host-neutral wire - one JSON event on stdin,
|
|
694
|
+
one JSON decision on stdout:
|
|
695
|
+
|
|
696
|
+
```json
|
|
697
|
+
{"protocol": 1, "phase": "pre", "cwd": "/repo", "tool": "edit",
|
|
698
|
+
"targets": [{"path": "/repo/app/svc.py", "op": "edit",
|
|
699
|
+
"expectedPresent": true,
|
|
700
|
+
"added": ["x = 2"], "removed": ["x = 1"]}],
|
|
701
|
+
"unknown": null}
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
```json
|
|
705
|
+
{"protocol": 1, "decision": "ask",
|
|
706
|
+
"reason": "approve suppression marker: app/svc.py: no-report: covered upstream"}
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
- `cwd` is the host session's non-empty absolute working directory.
|
|
710
|
+
- `phase` is `pre` (before the tool runs, still refusable) or `post` (after it
|
|
711
|
+
landed). Pre requests omit `succeeded`; post requests require the boolean
|
|
712
|
+
`succeeded`, so a failed tool is not misreported as a missing landed file.
|
|
713
|
+
- `tool` is one of `edit`, `apply_patch`, `write`, `bash`, or `eval`. `bash` and
|
|
714
|
+
`eval` are target-free wall-only channels.
|
|
715
|
+
- a **target** is one file mutation with an absolute `path`, `op`, and
|
|
716
|
+
`expectedPresent`. `edit` and `write` expect the path to exist, `delete`
|
|
717
|
+
expects it absent, and a move reports an absent source plus a present
|
|
718
|
+
destination. `content` is a full replacement; otherwise `added` and
|
|
719
|
+
`removed` are text fragments. `content` and fragments are mutually exclusive.
|
|
720
|
+
`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.
|
|
724
|
+
- the wire decisions are `allow`, `ask`, `block`, and `warn`. The semantic
|
|
725
|
+
outcomes are inactive, allow, approval-required, violation, and unverified:
|
|
726
|
+
unverified maps to `block` pre and `warn` post. Hosts must make a post warning
|
|
727
|
+
visible without turning a successful mutation into a repeatable tool error.
|
|
728
|
+
- exit is `0` whenever a decision was reached, whatever it says; `1` plus one
|
|
729
|
+
stderr line means no decision (unreadable stdin, or a protocol version this
|
|
730
|
+
tackbox does not speak).
|
|
731
|
+
|
|
620
732
|
## Escapes inventory
|
|
621
733
|
|
|
622
734
|
`tackbox escapes` prints the repo's whole bypass surface as JSON on
|
|
@@ -728,7 +840,7 @@ exit 1.
|
|
|
728
840
|
dev.py # lint / test / e2e / check (dev-script)
|
|
729
841
|
hygiene.py # dev.py lint hygiene (conflict/yaml/ws/newline)
|
|
730
842
|
go.mod # Go module
|
|
731
|
-
package.json # npm package (ESLint plugin + report helper)
|
|
843
|
+
package.json # npm package (ESLint plugin + OMP extension + report helper)
|
|
732
844
|
eslint.config.preset.js # default config used by tackbox-eslint bin
|
|
733
845
|
bin/tackbox-eslint.js # ESLint CLI wrapper with bundled preset
|
|
734
846
|
bin/tackbox-mdlint.js # markdownlint wrapper with bundled preset
|
|
@@ -749,6 +861,7 @@ js/
|
|
|
749
861
|
rules/ # 14 frontend rules
|
|
750
862
|
markdownlint-rules/ # custom markdownlint rules
|
|
751
863
|
report.js # browser capture helper (@sentry/browser)
|
|
864
|
+
omp/ # Oh My Pi extension (payload parser + hook-protocol client)
|
|
752
865
|
tests/ # RuleTester + node:test
|
|
753
866
|
py/
|
|
754
867
|
tackbox/ # lint / hook / doctor CLI, cache, engines
|
package/js/README.md
CHANGED
|
@@ -13,6 +13,14 @@ lint:
|
|
|
13
13
|
uvx tackbox@latest lint .
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
## Oh My Pi extension
|
|
17
|
+
|
|
18
|
+
The same npm package carries tackbox's Oh My Pi extension, so
|
|
19
|
+
`omp plugin install tackbox` wires the rules into an agent's edit loop
|
|
20
|
+
with nothing else to configure. See
|
|
21
|
+
[Agent hook](../README.md#agent-hook) for what it gates, how a decision
|
|
22
|
+
reaches the agent, and the development command override.
|
|
23
|
+
|
|
16
24
|
## Direct ESLint integration
|
|
17
25
|
|
|
18
26
|
To wire the plugin into your own ESLint run instead, install it from
|
package/js/omp/hook.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Spawn the pinned tackbox wheel and translate its protocol response.
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('node:child_process')
|
|
4
|
+
|
|
5
|
+
const PROTOCOL = 1
|
|
6
|
+
const SUBCOMMAND = 'hook-protocol'
|
|
7
|
+
const TIMEOUT_MS = 20000
|
|
8
|
+
const COMMAND_ENV = 'TACKBOX_OMP_COMMAND'
|
|
9
|
+
|
|
10
|
+
const ALLOW = 'allow'
|
|
11
|
+
const ASK = 'ask'
|
|
12
|
+
const BLOCK = 'block'
|
|
13
|
+
const UNVERIFIED = 'unverified'
|
|
14
|
+
const WIRE_WARN = 'warn'
|
|
15
|
+
const WIRE_KINDS = new Set([ALLOW, ASK, BLOCK, WIRE_WARN])
|
|
16
|
+
const DEFAULT_TIMERS = { set: setTimeout, clear: clearTimeout }
|
|
17
|
+
|
|
18
|
+
function request(phase, cwd, normalized) {
|
|
19
|
+
const event = {
|
|
20
|
+
protocol: PROTOCOL,
|
|
21
|
+
phase,
|
|
22
|
+
cwd,
|
|
23
|
+
tool: normalized.tool,
|
|
24
|
+
targets: normalized.targets,
|
|
25
|
+
unknown: normalized.unknown,
|
|
26
|
+
}
|
|
27
|
+
if (normalized.targetless !== undefined) event.targetless = normalized.targetless
|
|
28
|
+
if (phase === 'post') event.succeeded = normalized.succeeded
|
|
29
|
+
return event
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function decide(event, options) {
|
|
33
|
+
const argv = resolveArgv(options.env, options.version)
|
|
34
|
+
if (argv.error !== null) return unverified(argv.error)
|
|
35
|
+
const run = await execute(argv.value, event, options.timers || DEFAULT_TIMERS)
|
|
36
|
+
if (run.failure !== null) return unverified(run.failure)
|
|
37
|
+
if (run.code !== 0) {
|
|
38
|
+
const detail = firstLine(run.stderr) || `${argv.value[0]} exited with ${run.code}`
|
|
39
|
+
return unverified(detail)
|
|
40
|
+
}
|
|
41
|
+
const decoded = parseJson(run.stdout.trim())
|
|
42
|
+
if (decoded.error !== null) return unverified(`unreadable hook decision (${decoded.error})`)
|
|
43
|
+
return asDecision(decoded.value)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveArgv(env, version) {
|
|
47
|
+
const override = env[COMMAND_ENV]
|
|
48
|
+
if (typeof override !== 'string' || override.trim() === '') {
|
|
49
|
+
return { value: ['uvx', `tackbox@${version}`, SUBCOMMAND], error: null }
|
|
50
|
+
}
|
|
51
|
+
const decoded = parseJson(override)
|
|
52
|
+
const argv = decoded.value
|
|
53
|
+
const usable =
|
|
54
|
+
Array.isArray(argv) &&
|
|
55
|
+
argv.length > 0 &&
|
|
56
|
+
argv.every(value => typeof value === 'string' && value.trim() !== '')
|
|
57
|
+
if (!usable) {
|
|
58
|
+
return {
|
|
59
|
+
value: null,
|
|
60
|
+
error: `${COMMAND_ENV} must be a JSON array of argv strings; the tackbox hook did not run`,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { value: [...argv, SUBCOMMAND], error: null }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function execute(argv, event, timers, spawnChild = spawn) {
|
|
67
|
+
return new Promise(resolve => {
|
|
68
|
+
let child
|
|
69
|
+
// no-report: synchronous spawn failure resolves the protocol failure outcome.
|
|
70
|
+
try {
|
|
71
|
+
child = spawnChild(argv[0], argv.slice(1), {
|
|
72
|
+
cwd: event.cwd,
|
|
73
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
74
|
+
windowsHide: true,
|
|
75
|
+
})
|
|
76
|
+
} catch (error) {
|
|
77
|
+
resolve({ code: null, stdout: '', stderr: '', failure: `cannot run ${argv[0]}: ${message(error)}` })
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
if (!child.stdin || !child.stdout || !child.stderr) {
|
|
81
|
+
resolve({ code: null, stdout: '', stderr: '', failure: `cannot run ${argv[0]}: missing protocol stream` })
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
let stdout = ''
|
|
85
|
+
let stderr = ''
|
|
86
|
+
let timer = null
|
|
87
|
+
let settled = false
|
|
88
|
+
let closed = false
|
|
89
|
+
let closeCode = null
|
|
90
|
+
let stdinFinished = false
|
|
91
|
+
const finish = (failure, code) => {
|
|
92
|
+
if (settled) return
|
|
93
|
+
settled = true
|
|
94
|
+
if (timer !== null) timers.clear(timer)
|
|
95
|
+
resolve({ code, stdout, stderr, failure })
|
|
96
|
+
}
|
|
97
|
+
const stop = failure => {
|
|
98
|
+
let detail = failure
|
|
99
|
+
if (!child.kill()) detail += '; child had already exited'
|
|
100
|
+
finish(detail, null)
|
|
101
|
+
}
|
|
102
|
+
const finishClose = () => {
|
|
103
|
+
if (closed && stdinFinished) finish(null, closeCode)
|
|
104
|
+
}
|
|
105
|
+
timer = timers.set(() => {
|
|
106
|
+
stop(`the tackbox hook timed out after ${TIMEOUT_MS / 1000}s`)
|
|
107
|
+
}, TIMEOUT_MS)
|
|
108
|
+
child.stdout.setEncoding('utf8')
|
|
109
|
+
child.stdout.on('data', chunk => {
|
|
110
|
+
stdout += chunk
|
|
111
|
+
})
|
|
112
|
+
child.stdout.on('error', error => {
|
|
113
|
+
stop(`the tackbox hook stdout stream failed: ${message(error)}`)
|
|
114
|
+
})
|
|
115
|
+
child.stderr.setEncoding('utf8')
|
|
116
|
+
child.stderr.on('data', chunk => {
|
|
117
|
+
stderr += chunk
|
|
118
|
+
})
|
|
119
|
+
child.stderr.on('error', error => {
|
|
120
|
+
stop(`the tackbox hook stderr stream failed: ${message(error)}`)
|
|
121
|
+
})
|
|
122
|
+
child.on('error', error => finish(`cannot run ${argv[0]}: ${message(error)}`, null))
|
|
123
|
+
child.on('close', code => {
|
|
124
|
+
closed = true
|
|
125
|
+
closeCode = code
|
|
126
|
+
finishClose()
|
|
127
|
+
})
|
|
128
|
+
child.stdin.on('finish', () => {
|
|
129
|
+
stdinFinished = true
|
|
130
|
+
finishClose()
|
|
131
|
+
})
|
|
132
|
+
child.stdin.on('error', error => {
|
|
133
|
+
stop(`the tackbox hook stdin stream failed: ${message(error)}`)
|
|
134
|
+
})
|
|
135
|
+
// no-report: a synchronous stdin failure has the same unverified outcome as EPIPE.
|
|
136
|
+
try {
|
|
137
|
+
child.stdin.end(JSON.stringify(event))
|
|
138
|
+
} catch (error) {
|
|
139
|
+
stop(`the tackbox hook stdin stream failed: ${message(error)}`)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function asDecision(payload) {
|
|
145
|
+
if (!isObject(payload)) return unverified('the hook decision was not a JSON object')
|
|
146
|
+
if (!Number.isInteger(payload.protocol) || payload.protocol !== PROTOCOL) {
|
|
147
|
+
return unverified(
|
|
148
|
+
`the hook decision speaks protocol ${JSON.stringify(payload.protocol)}; this extension speaks ${PROTOCOL}`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
if (!WIRE_KINDS.has(payload.decision)) {
|
|
152
|
+
return unverified(`unknown hook decision ${JSON.stringify(payload.decision)}`)
|
|
153
|
+
}
|
|
154
|
+
if (typeof payload.reason !== 'string') {
|
|
155
|
+
return unverified('the hook decision carried a non-string reason')
|
|
156
|
+
}
|
|
157
|
+
if (payload.decision === ALLOW && payload.reason !== '') {
|
|
158
|
+
return unverified('the hook allow decision unexpectedly carried a reason')
|
|
159
|
+
}
|
|
160
|
+
if (payload.decision !== ALLOW && payload.reason.trim() === '') {
|
|
161
|
+
return unverified('the hook decision omitted its required reason')
|
|
162
|
+
}
|
|
163
|
+
if (payload.decision === WIRE_WARN) return unverified(payload.reason)
|
|
164
|
+
return { kind: payload.decision, reason: payload.reason }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseJson(text) {
|
|
168
|
+
// parse-skip: protocol JSON is intentionally parsed only at this boundary.
|
|
169
|
+
// no-report: malformed JSON becomes an explicit unverified outcome.
|
|
170
|
+
try {
|
|
171
|
+
return { value: JSON.parse(text), error: null }
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return { value: null, error: message(error) }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function unverified(reason) {
|
|
178
|
+
const text = String(reason)
|
|
179
|
+
return {
|
|
180
|
+
kind: UNVERIFIED,
|
|
181
|
+
reason: text.startsWith('tackbox') ? text : `tackbox: ${text}`,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function firstLine(text) {
|
|
186
|
+
for (const line of String(text || '').split('\n')) {
|
|
187
|
+
if (line.trim() !== '') return line.trim()
|
|
188
|
+
}
|
|
189
|
+
return ''
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isObject(value) {
|
|
193
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function message(error) {
|
|
197
|
+
return String((error && error.message) || error)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
ALLOW,
|
|
202
|
+
ASK,
|
|
203
|
+
BLOCK,
|
|
204
|
+
UNVERIFIED,
|
|
205
|
+
COMMAND_ENV,
|
|
206
|
+
TIMEOUT_MS,
|
|
207
|
+
decide,
|
|
208
|
+
execute,
|
|
209
|
+
request,
|
|
210
|
+
resolveArgv,
|
|
211
|
+
asDecision,
|
|
212
|
+
unverified,
|
|
213
|
+
}
|
package/js/omp/index.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Tackbox as an OMP extension over the strict shared hook protocol.
|
|
2
|
+
|
|
3
|
+
const payload = require('./payload')
|
|
4
|
+
const hook = require('./hook')
|
|
5
|
+
const manifest = require('../../package.json')
|
|
6
|
+
|
|
7
|
+
const CONFIRM_TITLE = 'tackbox approval'
|
|
8
|
+
const HEADLESS_NOTE =
|
|
9
|
+
'tackbox cannot ask here (no interactive session), so the call is blocked' +
|
|
10
|
+
' instead of approved. Re-issue it interactively, or drop the gated line.'
|
|
11
|
+
const DENIED = 'tackbox: approval denied.'
|
|
12
|
+
const BLOCKED = 'tackbox blocked this change:'
|
|
13
|
+
|
|
14
|
+
module.exports = function tackbox(pi) {
|
|
15
|
+
pi.setLabel('tackbox')
|
|
16
|
+
|
|
17
|
+
pi.on('tool_call', async (event, ctx) => {
|
|
18
|
+
const normalized = payload.normalize(event && event.toolName, event && event.input, ctx && ctx.cwd)
|
|
19
|
+
if (normalized === null) return undefined
|
|
20
|
+
if (normalized.failure) return applyPre(hook.unverified(normalized.failure), ctx)
|
|
21
|
+
if (normalized.unknown === null && normalized.targets.length === 0 && normalized.targetless === 'opaque') {
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
const decision = await hook.decide(hook.request('pre', ctx.cwd, normalized), options(ctx))
|
|
25
|
+
return applyPre(decision, ctx)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
pi.on('tool_result', async (event, ctx) => {
|
|
29
|
+
const normalized = payload.normalizeResult(
|
|
30
|
+
event && event.toolName,
|
|
31
|
+
event && event.details,
|
|
32
|
+
event && event.input,
|
|
33
|
+
ctx && ctx.cwd,
|
|
34
|
+
event && event.isError,
|
|
35
|
+
)
|
|
36
|
+
if (normalized === null) return undefined
|
|
37
|
+
const decision = normalized.failure
|
|
38
|
+
? hook.unverified(normalized.failure)
|
|
39
|
+
: await hook.decide(hook.request('post', ctx.cwd, normalized), options(ctx))
|
|
40
|
+
return applyPost(decision, event)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function applyPre(decision, ctx) {
|
|
45
|
+
if (decision.kind === hook.BLOCK || decision.kind === hook.UNVERIFIED) {
|
|
46
|
+
return { block: true, reason: decision.reason }
|
|
47
|
+
}
|
|
48
|
+
if (decision.kind === hook.ALLOW) return undefined
|
|
49
|
+
if (decision.kind !== hook.ASK) {
|
|
50
|
+
return { block: true, reason: `tackbox: unrecognized pre decision ${String(decision.kind)}` }
|
|
51
|
+
}
|
|
52
|
+
if (!ctx || !ctx.hasUI || !ctx.ui || typeof ctx.ui.confirm !== 'function') {
|
|
53
|
+
return { block: true, reason: `${decision.reason}\n\n${HEADLESS_NOTE}` }
|
|
54
|
+
}
|
|
55
|
+
const approved = await ctx.ui.confirm(CONFIRM_TITLE, decision.reason)
|
|
56
|
+
if (approved === true) return undefined
|
|
57
|
+
return { block: true, reason: `${DENIED}\n${decision.reason}` }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function applyPost(decision, event) {
|
|
61
|
+
const kind = decision && decision.kind
|
|
62
|
+
if (kind === hook.BLOCK) {
|
|
63
|
+
return {
|
|
64
|
+
content: appended(event, `${BLOCKED}\n${decision.reason}`),
|
|
65
|
+
isError: true,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (kind === hook.UNVERIFIED) {
|
|
69
|
+
return {
|
|
70
|
+
content: appended(event, unverifiedPostMessage(decision.reason)),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (kind === hook.ALLOW) return undefined
|
|
74
|
+
return {
|
|
75
|
+
content: appended(
|
|
76
|
+
event,
|
|
77
|
+
unverifiedPostMessage(`tackbox returned an unrecognized post decision ${String(kind)}`),
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function unverifiedPostMessage(reason) {
|
|
83
|
+
const text = typeof reason === 'string' ? reason : String(reason)
|
|
84
|
+
if (
|
|
85
|
+
text.includes('The mutation may already have landed.') &&
|
|
86
|
+
text.includes('Do not repeat the mutation; dev.py check remains required.')
|
|
87
|
+
) {
|
|
88
|
+
return text
|
|
89
|
+
}
|
|
90
|
+
return [
|
|
91
|
+
'The mutation may already have landed.',
|
|
92
|
+
`Tackbox verification did not complete: ${text}`,
|
|
93
|
+
'Do not repeat the mutation; dev.py check remains required.',
|
|
94
|
+
].join('\n')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function appended(event, text) {
|
|
98
|
+
const content = Array.isArray(event && event.content) ? event.content : []
|
|
99
|
+
return [...content, { type: 'text', text }]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function options(ctx) {
|
|
103
|
+
return {
|
|
104
|
+
env: process.env,
|
|
105
|
+
version: manifest.version,
|
|
106
|
+
timers: managedTimers(ctx),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function managedTimers(ctx) {
|
|
111
|
+
if (!ctx || typeof ctx.setTimeout !== 'function' || typeof ctx.clearTimer !== 'function') {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
set: (fn, ms) => ctx.setTimeout(fn, ms),
|
|
116
|
+
clear: handle => ctx.clearTimer(handle),
|
|
117
|
+
}
|
|
118
|
+
}
|
package/js/omp/index.mjs
ADDED
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
// OMP public tool events reduced to strict host-neutral hook protocol targets.
|
|
2
|
+
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
|
|
5
|
+
const TAGGED = /^\[(.+)#[0-9A-Fa-f]{4}\]\s*$/
|
|
6
|
+
const ENVELOPE = /^\*\*\* (?:Begin|End) Patch\s*$/
|
|
7
|
+
const FILE_SENTINEL = /^\*\*\* (Update|Add|Delete) File:\s*(.+?)\s*$/
|
|
8
|
+
const MOVE_SENTINEL = /^\*\*\* Move to:\s*(.+?)\s*$/
|
|
9
|
+
const MV_OP = /^MV\s+(.+?)\s*$/
|
|
10
|
+
const CUT_OP = /^CUT\b/
|
|
11
|
+
const REM_OP = /^REM\s*$/
|
|
12
|
+
const PUT_OP = /^PUT\b/
|
|
13
|
+
const HUNK = /^@@/
|
|
14
|
+
const PILCROW_HEADER = /^\s*\u00b6+(.*)$/u
|
|
15
|
+
const SLOPPY_HEADER = /^\[([^\]\n]+)\]\s*$/
|
|
16
|
+
const SLOPPY_SECTION = /^\u00a7(\*?)(.*)$/u
|
|
17
|
+
const SLOPPY_OPERATION = /^\u00ab/u
|
|
18
|
+
const URI_TARGET = /^[a-zA-Z][a-zA-Z0-9+.-]+:\/\//
|
|
19
|
+
const CONTAINER_TARGET = /\.(?:tar|tar\.gz|tgz|zip|jar|war|ear|apk|sqlite|sqlite3|db|db3):/i
|
|
20
|
+
const MESSAGE_CLIP = 120
|
|
21
|
+
|
|
22
|
+
function normalize(toolName, input, cwd) {
|
|
23
|
+
const tool = checkedTool(toolName)
|
|
24
|
+
if (tool.failure) return tool
|
|
25
|
+
if (tool.value === null) return null
|
|
26
|
+
const root = checkedRoot(cwd)
|
|
27
|
+
if (root.failure) return root
|
|
28
|
+
if (!isObject(input)) return unknown(tool.value, 'a non-object input')
|
|
29
|
+
if (tool.value === 'bash' || tool.value === 'eval') {
|
|
30
|
+
return normalized(tool.value, [], null, undefined, 'opaque')
|
|
31
|
+
}
|
|
32
|
+
if (tool.value === 'write') return normalizeWrite(tool.value, input, root.value)
|
|
33
|
+
return normalizeEdit(tool.value, input, root.value)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeResult(toolName, details, input, cwd, isError) {
|
|
37
|
+
const tool = checkedTool(toolName)
|
|
38
|
+
if (tool.failure) return tool
|
|
39
|
+
if (tool.value === null) return null
|
|
40
|
+
const root = checkedRoot(cwd)
|
|
41
|
+
if (root.failure) return root
|
|
42
|
+
if (typeof isError !== 'boolean') {
|
|
43
|
+
return failure('OMP supplied a non-boolean tool-result isError flag')
|
|
44
|
+
}
|
|
45
|
+
if (tool.value === 'bash' || tool.value === 'eval') {
|
|
46
|
+
return normalized(tool.value, [], null, !isError, 'opaque')
|
|
47
|
+
}
|
|
48
|
+
if (tool.value === 'write' && isOpaqueWrite(input)) {
|
|
49
|
+
return normalized(tool.value, [], null, !isError, 'opaque')
|
|
50
|
+
}
|
|
51
|
+
if (!isObject(details)) {
|
|
52
|
+
if (isError) return normalized(tool.value, [], null, false, 'failed')
|
|
53
|
+
return failure('OMP supplied no object result details for a successful file mutation')
|
|
54
|
+
}
|
|
55
|
+
const records = detailRecords(details)
|
|
56
|
+
if (records.failure) return records
|
|
57
|
+
// Pinned single-path aggregate errors do not identify which entries landed.
|
|
58
|
+
if (isError && !records.perFile) return normalized(tool.value, [], null, false, 'failed')
|
|
59
|
+
const decoded = []
|
|
60
|
+
for (const record of records.value) {
|
|
61
|
+
const parsed = detailTargets(record, root.value, tool.value)
|
|
62
|
+
if (parsed.failure) return parsed
|
|
63
|
+
if (parsed.failed) {
|
|
64
|
+
if (!isError) return failure('OMP reported a failed per-file result without an aggregate error')
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
decoded.push(parsed)
|
|
68
|
+
}
|
|
69
|
+
let targets = decoded.flatMap(result => result.targets)
|
|
70
|
+
if (decoded.some(result => result.snapshotsPruned)) {
|
|
71
|
+
const fallback = normalize(tool.value, input, root.value)
|
|
72
|
+
if (
|
|
73
|
+
fallback === null ||
|
|
74
|
+
fallback.failure ||
|
|
75
|
+
fallback.unknown !== null ||
|
|
76
|
+
fallback.targetless !== undefined
|
|
77
|
+
) {
|
|
78
|
+
return failure('OMP pruned result snapshots and the original input cannot be matched safely')
|
|
79
|
+
}
|
|
80
|
+
const unused = new Set(fallback.targets.map((_, index) => index))
|
|
81
|
+
targets = []
|
|
82
|
+
for (const parsed of decoded) {
|
|
83
|
+
if (!parsed.snapshotsPruned) {
|
|
84
|
+
targets.push(...parsed.targets)
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
const merged = mergePrunedTargets(parsed.targets, fallback.targets, unused)
|
|
88
|
+
if (merged.failure) return merged
|
|
89
|
+
targets.push(...merged.value)
|
|
90
|
+
}
|
|
91
|
+
if (!records.perFile && unused.size > 0) {
|
|
92
|
+
return failure('OMP result details omitted one or more mutated input targets')
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (targets.length === 0) {
|
|
96
|
+
const targetless = isError
|
|
97
|
+
? 'failed'
|
|
98
|
+
: decoded.every(result => result.targetless === 'no-op')
|
|
99
|
+
? 'no-op'
|
|
100
|
+
: null
|
|
101
|
+
if (targetless === null) return failure('OMP result details name no landed target')
|
|
102
|
+
return normalized(tool.value, [], null, !isError, targetless)
|
|
103
|
+
}
|
|
104
|
+
return normalized(tool.value, targets, null, !isError)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function checkedTool(toolName) {
|
|
108
|
+
if (typeof toolName !== 'string' || toolName.trim() === '') {
|
|
109
|
+
return failure('OMP supplied a malformed toolName')
|
|
110
|
+
}
|
|
111
|
+
if (!['edit', 'apply_patch', 'write', 'bash', 'eval'].includes(toolName)) {
|
|
112
|
+
return { value: null }
|
|
113
|
+
}
|
|
114
|
+
return { value: toolName }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function checkedRoot(cwd) {
|
|
118
|
+
if (typeof cwd !== 'string' || cwd === '' || !path.isAbsolute(cwd)) {
|
|
119
|
+
return failure('OMP supplied no absolute session cwd')
|
|
120
|
+
}
|
|
121
|
+
return { value: cwd }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sessionRoot(cwd) {
|
|
125
|
+
const root = checkedRoot(cwd)
|
|
126
|
+
return root.failure ? null : root.value
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeWrite(tool, input, cwd) {
|
|
130
|
+
const raw = untag(stringOrNull(input.path))
|
|
131
|
+
if (raw === null) return unknown(tool, 'a write with no string path')
|
|
132
|
+
if (isOpaquePath(raw)) return normalized(tool, [], null, undefined, 'opaque')
|
|
133
|
+
const content = stringOrNull(input.content)
|
|
134
|
+
if (content === null) return unknown(tool, `a write to ${clip(raw)} with no string content`)
|
|
135
|
+
return normalized(tool, [target(path.resolve(cwd, raw), 'write', true, { content })], null)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function normalizeEdit(tool, input, cwd) {
|
|
139
|
+
const patchInput = stringOrNull(input.input) || stringOrNull(input._input)
|
|
140
|
+
if (patchInput !== null) return parsePatchText(tool, patchInput, cwd)
|
|
141
|
+
if (typeof input.old_string === 'string' || typeof input.new_string === 'string') {
|
|
142
|
+
return normalizeReplace(tool, input, cwd)
|
|
143
|
+
}
|
|
144
|
+
if (Array.isArray(input.edits)) return normalizeEdits(tool, input, cwd)
|
|
145
|
+
return normalizePublicPaths(tool, input, cwd)
|
|
146
|
+
}
|
|
147
|
+
function normalizePublicPaths(tool, input, cwd) {
|
|
148
|
+
if (input.paths !== undefined) {
|
|
149
|
+
if (!Array.isArray(input.paths) || input.paths.length === 0) {
|
|
150
|
+
return unknown(tool, 'an OMP paths compatibility field that is not a non-empty array')
|
|
151
|
+
}
|
|
152
|
+
const paths = []
|
|
153
|
+
const seen = new Set()
|
|
154
|
+
for (const value of input.paths) {
|
|
155
|
+
const raw = untag(stringOrNull(value))
|
|
156
|
+
if (raw === null) return unknown(tool, 'an OMP paths compatibility field with a non-string path')
|
|
157
|
+
if (!seen.has(raw)) {
|
|
158
|
+
seen.add(raw)
|
|
159
|
+
paths.push(raw)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const direct = untag(stringOrNull(input.path))
|
|
163
|
+
if (direct !== null && !seen.has(direct)) {
|
|
164
|
+
return unknown(tool, 'conflicting OMP path and paths compatibility fields')
|
|
165
|
+
}
|
|
166
|
+
return normalized(
|
|
167
|
+
tool,
|
|
168
|
+
paths.map(raw => target(path.resolve(cwd, raw), 'edit', true, { ambiguous: true })),
|
|
169
|
+
null,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
const raw = editPath(input)
|
|
173
|
+
if (raw === null) {
|
|
174
|
+
return unknown(tool, `an ${tool} payload with no input, old_string/new_string, edits, path, or paths field`)
|
|
175
|
+
}
|
|
176
|
+
return normalized(tool, [target(path.resolve(cwd, raw), 'edit', true, { ambiguous: true })], null)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
function normalizeReplace(tool, input, cwd) {
|
|
181
|
+
const raw = editPath(input)
|
|
182
|
+
if (raw === null) return unknown(tool, 'a replace edit with no string path')
|
|
183
|
+
return normalized(tool, [
|
|
184
|
+
target(path.resolve(cwd, raw), 'edit', true, {
|
|
185
|
+
added: [typeof input.new_string === 'string' ? input.new_string : ''],
|
|
186
|
+
removed: [typeof input.old_string === 'string' ? input.old_string : ''],
|
|
187
|
+
}),
|
|
188
|
+
], null)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function normalizeEdits(tool, input, cwd) {
|
|
192
|
+
const raw = editPath(input)
|
|
193
|
+
if (raw === null) return unknown(tool, 'a patch payload with no string path')
|
|
194
|
+
if (input.edits.length === 0) return unknown(tool, 'a patch payload with an empty edits list')
|
|
195
|
+
const drafts = new Map()
|
|
196
|
+
const source = draftFor(drafts, path.resolve(cwd, raw))
|
|
197
|
+
for (const entry of input.edits) {
|
|
198
|
+
if (!isObject(entry)) return unknown(tool, 'a patch edit that is not an object')
|
|
199
|
+
const op = entry.op === undefined ? 'update' : entry.op
|
|
200
|
+
if (typeof op !== 'string') return unknown(tool, 'a patch edit with a non-string op')
|
|
201
|
+
if (entry.rename !== undefined) {
|
|
202
|
+
const renamed = stringOrNull(entry.rename)
|
|
203
|
+
if (renamed === null) source.ambiguous = true
|
|
204
|
+
else markMove(source, draftFor(drafts, path.resolve(cwd, renamed)))
|
|
205
|
+
}
|
|
206
|
+
const destination = source.operation === 'move' ? moveDestination(drafts, source) : source
|
|
207
|
+
if (op === 'delete') {
|
|
208
|
+
markDelete(source)
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
if (op === 'create') {
|
|
212
|
+
destination.operation = 'write'
|
|
213
|
+
destination.expectedPresent = true
|
|
214
|
+
if (
|
|
215
|
+
input.edits.length === 1 &&
|
|
216
|
+
entry.rename === undefined &&
|
|
217
|
+
typeof entry.diff === 'string' &&
|
|
218
|
+
entry.diff.length > 0
|
|
219
|
+
) {
|
|
220
|
+
setContent(destination, entry.diff)
|
|
221
|
+
} else {
|
|
222
|
+
destination.ambiguous = true
|
|
223
|
+
}
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
if (op !== 'update' || typeof entry.diff !== 'string' || entry.diff.length === 0) {
|
|
227
|
+
destination.ambiguous = true
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
appendPatchFragments(destination, entry.diff)
|
|
231
|
+
}
|
|
232
|
+
return normalized(tool, finalizeDrafts(drafts), null)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parsePatchText(tool, text, cwd) {
|
|
236
|
+
const drafts = new Map()
|
|
237
|
+
let current = null
|
|
238
|
+
let run = []
|
|
239
|
+
const flush = () => {
|
|
240
|
+
if (run.length > 0 && current !== null) current.added.push(run.join('\n'))
|
|
241
|
+
run = []
|
|
242
|
+
}
|
|
243
|
+
const open = (raw, operation = 'edit') => {
|
|
244
|
+
flush()
|
|
245
|
+
current = draftFor(drafts, path.resolve(cwd, unquote(raw)))
|
|
246
|
+
if (operation === 'write') current.operation = 'write'
|
|
247
|
+
if (operation === 'delete') markDelete(current)
|
|
248
|
+
}
|
|
249
|
+
const lines = text.split('\n')
|
|
250
|
+
for (let index = 0; index < lines.length; index++) {
|
|
251
|
+
const rawLine = lines[index]
|
|
252
|
+
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
|
|
253
|
+
if (line.startsWith('+')) {
|
|
254
|
+
if (current === null) return unknown(tool, `an added row before any file section: ${clip(line)}`)
|
|
255
|
+
run.push(line.slice(1))
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
flush()
|
|
259
|
+
if (line.trim() === '' || ENVELOPE.test(line) || HUNK.test(line)) continue
|
|
260
|
+
const tagged = TAGGED.exec(line)
|
|
261
|
+
if (tagged !== null) {
|
|
262
|
+
open(tagged[1])
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
const pilcrow = PILCROW_HEADER.exec(line)
|
|
266
|
+
if (pilcrow !== null) {
|
|
267
|
+
const compatPath = pilcrowPath(pilcrow[1])
|
|
268
|
+
if (compatPath === null) return unknown(tool, 'a paragraph-sign header with no path')
|
|
269
|
+
open(compatPath)
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
const sloppy = SLOPPY_SECTION.exec(line)
|
|
273
|
+
if (sloppy !== null) {
|
|
274
|
+
const sloppyPath = sloppy[2].trim()
|
|
275
|
+
if (sloppyPath !== '') {
|
|
276
|
+
open(sloppyPath)
|
|
277
|
+
} else if (current === null) {
|
|
278
|
+
return unknown(tool, 'a sloppy operation before any file section')
|
|
279
|
+
}
|
|
280
|
+
current.ambiguous = true
|
|
281
|
+
continue
|
|
282
|
+
}
|
|
283
|
+
const sloppyHeader = SLOPPY_HEADER.exec(line)
|
|
284
|
+
if (
|
|
285
|
+
sloppyHeader !== null &&
|
|
286
|
+
(current === null || startsSloppyOperation(lines, index + 1))
|
|
287
|
+
) {
|
|
288
|
+
const sloppyPath = sloppyHeader[1].trim()
|
|
289
|
+
if (sloppyPath === '') return unknown(tool, 'a sloppy [path] header with no path')
|
|
290
|
+
open(sloppyPath)
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
const sentinel = FILE_SENTINEL.exec(line)
|
|
294
|
+
if (sentinel !== null) {
|
|
295
|
+
open(sentinel[2], sentinel[1] === 'Add' ? 'write' : sentinel[1] === 'Delete' ? 'delete' : 'edit')
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
const move = MV_OP.exec(line) || MOVE_SENTINEL.exec(line)
|
|
299
|
+
if (move !== null) {
|
|
300
|
+
if (current === null) return unknown(tool, 'a move operation before any file section')
|
|
301
|
+
const destination = draftFor(drafts, path.resolve(cwd, unquote(move[1])))
|
|
302
|
+
markMove(current, destination)
|
|
303
|
+
current = destination
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
if (line.startsWith('-') || line.startsWith(' ')) {
|
|
307
|
+
if (current === null) return unknown(tool, `a patch row before any file section: ${clip(line)}`)
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
if (REM_OP.test(line)) {
|
|
311
|
+
if (current === null) return unknown(tool, 'a remove operation before any file section')
|
|
312
|
+
markDelete(current)
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
if (CUT_OP.test(line)) {
|
|
316
|
+
if (current === null) return unknown(tool, 'a cut operation before any file section')
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
if (PUT_OP.test(line)) {
|
|
320
|
+
if (current === null) return unknown(tool, 'a put operation before any file section')
|
|
321
|
+
if (!line.endsWith(':')) current.ambiguous = true
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
if (current === null) return unknown(tool, `an unrecognized patch row before any file section: ${clip(line)}`)
|
|
325
|
+
current.ambiguous = true
|
|
326
|
+
}
|
|
327
|
+
flush()
|
|
328
|
+
if (drafts.size === 0) {
|
|
329
|
+
return unknown(tool, 'a patch payload with no [PATH#TAG], paragraph-sign, sloppy, or `*** ... File:` section')
|
|
330
|
+
}
|
|
331
|
+
return normalized(tool, finalizeDrafts(drafts), null)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function startsSloppyOperation(lines, from) {
|
|
335
|
+
for (let index = from; index < lines.length; index++) {
|
|
336
|
+
const trimmed = lines[index].trim()
|
|
337
|
+
if (trimmed === '') continue
|
|
338
|
+
return SLOPPY_SECTION.test(trimmed) || SLOPPY_OPERATION.test(trimmed)
|
|
339
|
+
}
|
|
340
|
+
return false
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function pilcrowPath(value) {
|
|
344
|
+
const trimmed = value.trim()
|
|
345
|
+
if (trimmed === '') return null
|
|
346
|
+
const tag = /#[0-9A-Fa-f]{4}$/.exec(trimmed)
|
|
347
|
+
const raw = tag === null ? trimmed : trimmed.slice(0, tag.index)
|
|
348
|
+
const unquoted = unquote(raw)
|
|
349
|
+
return unquoted === '' ? null : unquoted
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function detailRecords(details) {
|
|
353
|
+
if (details.perFileResults === undefined) return { value: [details], perFile: false }
|
|
354
|
+
if (!Array.isArray(details.perFileResults) || details.perFileResults.length === 0) {
|
|
355
|
+
return failure('OMP supplied malformed perFileResults')
|
|
356
|
+
}
|
|
357
|
+
const records = []
|
|
358
|
+
for (const result of details.perFileResults) {
|
|
359
|
+
if (!isObject(result)) return failure('OMP supplied a non-object perFileResults entry')
|
|
360
|
+
records.push({
|
|
361
|
+
...result,
|
|
362
|
+
snapshotsPruned: result.snapshotsPruned === undefined
|
|
363
|
+
? details.snapshotsPruned
|
|
364
|
+
: result.snapshotsPruned,
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
return { value: records, perFile: true }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function detailTargets(record, cwd, tool) {
|
|
371
|
+
const valid = validateDetail(record)
|
|
372
|
+
if (valid.failure) return valid
|
|
373
|
+
if (record.isError === true) {
|
|
374
|
+
return { targets: [], snapshotsPruned: false, targetless: null, failed: true }
|
|
375
|
+
}
|
|
376
|
+
const operation = detailOperation(record, tool)
|
|
377
|
+
if (operation.failure) return operation
|
|
378
|
+
if (tool === 'write' && operation.value !== 'write') {
|
|
379
|
+
return failure('OMP write result details carry a non-write operation')
|
|
380
|
+
}
|
|
381
|
+
const paths = detailPaths(record, operation.value, cwd, tool)
|
|
382
|
+
if (paths.failure) return paths
|
|
383
|
+
const targets = paths.value
|
|
384
|
+
const snapshotsPruned = record.snapshotsPruned === true
|
|
385
|
+
const destination = targets.find(item => item.expectedPresent)
|
|
386
|
+
if (destination !== undefined) {
|
|
387
|
+
if (typeof record.newText === 'string') {
|
|
388
|
+
delete destination.added
|
|
389
|
+
delete destination.removed
|
|
390
|
+
delete destination.ambiguous
|
|
391
|
+
destination.content = record.newText
|
|
392
|
+
} else if (!snapshotsPruned) {
|
|
393
|
+
destination.ambiguous = true
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
targets,
|
|
398
|
+
snapshotsPruned,
|
|
399
|
+
targetless: paths.targetless || null,
|
|
400
|
+
failed: false,
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function validateDetail(record) {
|
|
405
|
+
for (const key of ['path', 'resolvedPath', 'sourcePath', 'op', 'oldText', 'newText', 'diff', 'errorText']) {
|
|
406
|
+
if (record[key] !== undefined && typeof record[key] !== 'string') {
|
|
407
|
+
return failure(`OMP supplied a non-string result details.${key}`)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (record.isError !== undefined && typeof record.isError !== 'boolean') {
|
|
411
|
+
return failure('OMP supplied a non-boolean result details.isError')
|
|
412
|
+
}
|
|
413
|
+
if (record.errorText !== undefined && record.isError !== true) {
|
|
414
|
+
return failure('OMP supplied errorText without a failed result record')
|
|
415
|
+
}
|
|
416
|
+
if (record.snapshotsPruned !== undefined && typeof record.snapshotsPruned !== 'boolean') {
|
|
417
|
+
return failure('OMP supplied a non-boolean result details.snapshotsPruned')
|
|
418
|
+
}
|
|
419
|
+
if (
|
|
420
|
+
record.move !== undefined &&
|
|
421
|
+
typeof record.move !== 'boolean' &&
|
|
422
|
+
typeof record.move !== 'string' &&
|
|
423
|
+
!isObject(record.move)
|
|
424
|
+
) {
|
|
425
|
+
return failure('OMP supplied malformed result details.move')
|
|
426
|
+
}
|
|
427
|
+
return { value: true }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function detailOperation(record, tool) {
|
|
431
|
+
const raw = record.op
|
|
432
|
+
const move = hasMove(record)
|
|
433
|
+
if (raw === undefined) return { value: move ? 'move' : tool === 'write' ? 'write' : 'edit' }
|
|
434
|
+
if (['update', 'replace', 'patch', 'edit', 'apply_patch'].includes(raw)) {
|
|
435
|
+
return { value: move ? 'move' : 'edit' }
|
|
436
|
+
}
|
|
437
|
+
if (['create', 'write'].includes(raw)) return { value: 'write' }
|
|
438
|
+
if (raw === 'delete') return { value: 'delete' }
|
|
439
|
+
if (['move', 'rename'].includes(raw)) return { value: 'move' }
|
|
440
|
+
return failure(`OMP supplied an unsupported result details.op ${JSON.stringify(raw)}`)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function detailPaths(record, operation, cwd, tool) {
|
|
444
|
+
const source = detailString(record, 'sourcePath') || movePath(record.move, 'source')
|
|
445
|
+
const declaredPath = detailString(record, 'path')
|
|
446
|
+
const resolvedPath = detailString(record, 'resolvedPath')
|
|
447
|
+
const destination = tool === 'write'
|
|
448
|
+
? resolvedPath || declaredPath || movePath(record.move, 'destination')
|
|
449
|
+
: declaredPath || resolvedPath || movePath(record.move, 'destination')
|
|
450
|
+
if (operation === 'delete') {
|
|
451
|
+
const deleted = source || destination
|
|
452
|
+
if (deleted === null) return failure('OMP delete result details name no source path')
|
|
453
|
+
return { value: [target(path.resolve(cwd, deleted), 'delete', false)] }
|
|
454
|
+
}
|
|
455
|
+
if (operation === 'move') {
|
|
456
|
+
if (source === null || destination === null) {
|
|
457
|
+
return failure('OMP move result details must name both sourcePath and path')
|
|
458
|
+
}
|
|
459
|
+
const sourcePath = path.resolve(cwd, source)
|
|
460
|
+
const destinationPath = path.resolve(cwd, destination)
|
|
461
|
+
const pair = moveId(sourcePath, destinationPath)
|
|
462
|
+
return {
|
|
463
|
+
value: [
|
|
464
|
+
target(sourcePath, 'move', false, { moveId: pair }),
|
|
465
|
+
target(destinationPath, 'move', true, { ambiguous: true, moveId: pair }),
|
|
466
|
+
],
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const landed = destination || source
|
|
470
|
+
if (landed === null && operation === 'edit') {
|
|
471
|
+
if (isExactNoop(record)) return { value: [], targetless: 'no-op' }
|
|
472
|
+
if (hasPathlessMutation(record)) {
|
|
473
|
+
return failure('OMP supplied mutating pathless edit result details')
|
|
474
|
+
}
|
|
475
|
+
return failure('OMP supplied malformed pathless edit result details')
|
|
476
|
+
}
|
|
477
|
+
if (landed === null) return failure('OMP result details name no landed path')
|
|
478
|
+
return { value: [target(path.resolve(cwd, landed), operation, true, { ambiguous: true })] }
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function isExactNoop(record) {
|
|
482
|
+
return (
|
|
483
|
+
record.op === 'update' &&
|
|
484
|
+
record.diff === '' &&
|
|
485
|
+
record.oldText === undefined &&
|
|
486
|
+
record.newText === undefined &&
|
|
487
|
+
record.snapshotsPruned !== true &&
|
|
488
|
+
!hasMove(record)
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function hasPathlessMutation(record) {
|
|
493
|
+
return ['diff', 'oldText', 'newText'].some(
|
|
494
|
+
key => typeof record[key] === 'string' && record[key] !== '',
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function mergePrunedTargets(actual, intended, unused) {
|
|
499
|
+
const merged = []
|
|
500
|
+
for (const targetFromResult of actual) {
|
|
501
|
+
const match = [...unused].find(index => sameTarget(targetFromResult, intended[index]))
|
|
502
|
+
if (match === undefined) {
|
|
503
|
+
return failure(`OMP pruned snapshots for ${targetFromResult.path}, which input did not match`)
|
|
504
|
+
}
|
|
505
|
+
unused.delete(match)
|
|
506
|
+
const intent = intended[match]
|
|
507
|
+
const result = {
|
|
508
|
+
path: targetFromResult.path,
|
|
509
|
+
op: targetFromResult.op,
|
|
510
|
+
expectedPresent: targetFromResult.expectedPresent,
|
|
511
|
+
}
|
|
512
|
+
if (targetFromResult.moveId !== undefined) result.moveId = targetFromResult.moveId
|
|
513
|
+
if (carriesLandedContent(result)) {
|
|
514
|
+
if (intent.content !== undefined) result.content = intent.content
|
|
515
|
+
else {
|
|
516
|
+
result.added = intent.added || []
|
|
517
|
+
result.removed = intent.removed || []
|
|
518
|
+
if (intent.ambiguous) result.ambiguous = true
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
merged.push(result)
|
|
522
|
+
}
|
|
523
|
+
return { value: merged }
|
|
524
|
+
}
|
|
525
|
+
function carriesLandedContent(value) {
|
|
526
|
+
return value.expectedPresent && value.op !== 'delete'
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function sameTarget(left, right) {
|
|
530
|
+
return left.path === right.path &&
|
|
531
|
+
left.op === right.op &&
|
|
532
|
+
left.expectedPresent === right.expectedPresent
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function moveId(source, destination) {
|
|
536
|
+
return JSON.stringify([source, destination])
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
function hasMove(record) {
|
|
541
|
+
return record.move === true || typeof record.move === 'string' || isObject(record.move)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function detailString(record, key) {
|
|
545
|
+
return typeof record[key] === 'string' && record[key].trim() !== '' ? record[key] : null
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function movePath(move, side) {
|
|
549
|
+
if (typeof move === 'string') return side === 'destination' ? move : null
|
|
550
|
+
if (!isObject(move)) return null
|
|
551
|
+
const keys = side === 'source' ? ['sourcePath', 'source', 'from'] : ['path', 'destination', 'to']
|
|
552
|
+
for (const key of keys) {
|
|
553
|
+
if (typeof move[key] === 'string' && move[key].trim() !== '') return move[key]
|
|
554
|
+
}
|
|
555
|
+
return null
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function target(file, operation, expectedPresent, fields = {}) {
|
|
559
|
+
return { path: file, op: operation, expectedPresent, ...fields }
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function draftFor(drafts, file) {
|
|
563
|
+
const existing = drafts.get(file)
|
|
564
|
+
if (existing !== undefined) return existing
|
|
565
|
+
const draft = {
|
|
566
|
+
path: file,
|
|
567
|
+
operation: 'edit',
|
|
568
|
+
expectedPresent: true,
|
|
569
|
+
added: [],
|
|
570
|
+
removed: [],
|
|
571
|
+
ambiguous: false,
|
|
572
|
+
moveDestination: null,
|
|
573
|
+
moveId: null,
|
|
574
|
+
}
|
|
575
|
+
drafts.set(file, draft)
|
|
576
|
+
return draft
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function markDelete(draft) {
|
|
580
|
+
draft.operation = 'delete'
|
|
581
|
+
draft.expectedPresent = false
|
|
582
|
+
draft.added = []
|
|
583
|
+
draft.content = undefined
|
|
584
|
+
draft.ambiguous = false
|
|
585
|
+
draft.moveDestination = null
|
|
586
|
+
draft.moveId = null
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function markMove(source, destination) {
|
|
590
|
+
const pair = moveId(source.path, destination.path)
|
|
591
|
+
source.operation = 'move'
|
|
592
|
+
source.expectedPresent = false
|
|
593
|
+
source.added = []
|
|
594
|
+
source.content = undefined
|
|
595
|
+
source.ambiguous = false
|
|
596
|
+
source.moveDestination = destination.path
|
|
597
|
+
source.moveId = pair
|
|
598
|
+
destination.operation = 'move'
|
|
599
|
+
destination.expectedPresent = true
|
|
600
|
+
destination.ambiguous = true
|
|
601
|
+
destination.moveId = pair
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function moveDestination(drafts, source) {
|
|
605
|
+
return source.moveDestination === null ? source : draftFor(drafts, source.moveDestination)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function setContent(draft, content) {
|
|
609
|
+
draft.content = content.endsWith('\n') ? content : `${content}\n`
|
|
610
|
+
draft.added = []
|
|
611
|
+
draft.removed = []
|
|
612
|
+
draft.ambiguous = false
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
function finalizeDrafts(drafts) {
|
|
617
|
+
return [...drafts.values()].map(finalizeDraft)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function finalizeDraft(draft) {
|
|
621
|
+
const value = target(draft.path, draft.operation, draft.expectedPresent)
|
|
622
|
+
if (draft.operation === 'move' && draft.moveId !== null) value.moveId = draft.moveId
|
|
623
|
+
if (draft.content !== undefined) {
|
|
624
|
+
value.content = draft.content
|
|
625
|
+
return value
|
|
626
|
+
}
|
|
627
|
+
if (draft.operation !== 'delete' && !(draft.operation === 'move' && !draft.expectedPresent)) {
|
|
628
|
+
value.added = draft.added
|
|
629
|
+
value.removed = draft.removed
|
|
630
|
+
}
|
|
631
|
+
if (draft.ambiguous) value.ambiguous = true
|
|
632
|
+
return value
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function appendPatchFragments(targetDraft, diff) {
|
|
636
|
+
let inHunk = false
|
|
637
|
+
let added = []
|
|
638
|
+
let removed = []
|
|
639
|
+
const flush = () => {
|
|
640
|
+
if (added.length > 0) targetDraft.added.push(added.join('\n'))
|
|
641
|
+
if (removed.length > 0) targetDraft.removed.push(removed.join('\n'))
|
|
642
|
+
added = []
|
|
643
|
+
removed = []
|
|
644
|
+
}
|
|
645
|
+
const lines = diff.split('\n')
|
|
646
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
647
|
+
const raw = lines[index]
|
|
648
|
+
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw
|
|
649
|
+
if (line.startsWith('@@')) {
|
|
650
|
+
flush()
|
|
651
|
+
inHunk = true
|
|
652
|
+
continue
|
|
653
|
+
}
|
|
654
|
+
if (line === '' && index === lines.length - 1) continue
|
|
655
|
+
if (!inHunk) {
|
|
656
|
+
targetDraft.ambiguous = true
|
|
657
|
+
continue
|
|
658
|
+
}
|
|
659
|
+
if (line.startsWith('+')) {
|
|
660
|
+
if (removed.length > 0) {
|
|
661
|
+
targetDraft.removed.push(removed.join('\n'))
|
|
662
|
+
removed = []
|
|
663
|
+
}
|
|
664
|
+
added.push(line.slice(1))
|
|
665
|
+
continue
|
|
666
|
+
}
|
|
667
|
+
if (line.startsWith('-')) {
|
|
668
|
+
if (added.length > 0) {
|
|
669
|
+
targetDraft.added.push(added.join('\n'))
|
|
670
|
+
added = []
|
|
671
|
+
}
|
|
672
|
+
removed.push(line.slice(1))
|
|
673
|
+
continue
|
|
674
|
+
}
|
|
675
|
+
if (line.startsWith(' ')) {
|
|
676
|
+
flush()
|
|
677
|
+
continue
|
|
678
|
+
}
|
|
679
|
+
targetDraft.ambiguous = true
|
|
680
|
+
}
|
|
681
|
+
flush()
|
|
682
|
+
if (!inHunk) targetDraft.ambiguous = true
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
function editPath(input) {
|
|
687
|
+
return stringOrNull(input.path) || stringOrNull(input._path) || stringOrNull(input.file_path)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function stringOrNull(value) {
|
|
691
|
+
if (typeof value !== 'string' || value.trim() === '') return null
|
|
692
|
+
return value
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function untag(value) {
|
|
696
|
+
if (value === null) return null
|
|
697
|
+
const tagged = TAGGED.exec(value)
|
|
698
|
+
return tagged === null ? value : tagged[1].trim()
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function unquote(value) {
|
|
702
|
+
const trimmed = value.trim()
|
|
703
|
+
if (
|
|
704
|
+
trimmed.length >= 2 &&
|
|
705
|
+
((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
706
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'")))
|
|
707
|
+
) {
|
|
708
|
+
return trimmed.slice(1, -1)
|
|
709
|
+
}
|
|
710
|
+
return trimmed
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function normalized(tool, targets, unknown, succeeded, targetless) {
|
|
714
|
+
const value = { tool, targets, unknown }
|
|
715
|
+
if (succeeded !== undefined) value.succeeded = succeeded
|
|
716
|
+
if (targetless !== undefined) value.targetless = targetless
|
|
717
|
+
return value
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function unknown(tool, detail) {
|
|
721
|
+
return normalized(
|
|
722
|
+
tool,
|
|
723
|
+
[],
|
|
724
|
+
`tackbox cannot classify this ${tool} call (${detail}). Re-issue it in a documented form; dev.py check remains required.`,
|
|
725
|
+
)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function failure(reason) {
|
|
729
|
+
return { failure: `tackbox cannot verify this hook event: ${reason}` }
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function isOpaqueWrite(input) {
|
|
733
|
+
return isObject(input) && isOpaquePath(untag(stringOrNull(input.path)))
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function isOpaquePath(value) {
|
|
737
|
+
return value !== null && (URI_TARGET.test(value) || CONTAINER_TARGET.test(value))
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function isObject(value) {
|
|
741
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function clip(text) {
|
|
745
|
+
const flat = text.replace(/\s+/g, ' ').trim()
|
|
746
|
+
return flat.length > MESSAGE_CLIP ? `${flat.slice(0, MESSAGE_CLIP)}...` : flat
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
module.exports = { normalize, normalizeResult, sessionRoot }
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tackbox",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "ESLint and Markdown lint plugins plus direct error-reporting helpers for JavaScript and TypeScript.",
|
|
3
|
+
"version": "0.1.89",
|
|
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",
|
|
7
7
|
"exports": {
|
|
@@ -12,11 +12,20 @@
|
|
|
12
12
|
"tackbox-eslint": "./bin/tackbox-eslint.js",
|
|
13
13
|
"tackbox-mdlint": "./bin/tackbox-mdlint.js"
|
|
14
14
|
},
|
|
15
|
+
"omp": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"./js/omp/index.mjs"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
15
20
|
"files": [
|
|
16
21
|
"js/eslint-plugin.js",
|
|
17
22
|
"js/report.js",
|
|
18
23
|
"js/rules/",
|
|
19
24
|
"js/markdownlint-rules/",
|
|
25
|
+
"js/omp/index.mjs",
|
|
26
|
+
"js/omp/index.js",
|
|
27
|
+
"js/omp/hook.js",
|
|
28
|
+
"js/omp/payload.js",
|
|
20
29
|
"js/README.md",
|
|
21
30
|
"bin/",
|
|
22
31
|
"eslint.config.preset.js"
|