@hasna/hooks 0.8.0 → 0.9.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/bin/index.js +315 -67
- package/bin/serve.js +12 -0
- package/dist/index.js +52 -4
- package/dist/lib/installer.d.ts +40 -1
- package/dist/lib/registration.d.ts +75 -0
- package/dist/lib/registry.d.ts +14 -0
- package/hooks/hook-agent-rules-version-check/README.md +1 -1
- package/hooks/hook-trash-guard/README.md +132 -0
- package/hooks/hook-trash-guard/package.json +12 -0
- package/hooks/hook-trash-guard/src/hook.ts +1114 -0
- package/package.json +4 -2
- package/hooks/codewith-native-common.test.ts +0 -1935
- package/hooks/hook-affected-tests/tsconfig.json +0 -25
- package/hooks/hook-agent-rules-version-check/src/hook.test.ts +0 -104
- package/hooks/hook-agent-rules-version-check/tsconfig.json +0 -25
- package/hooks/hook-announce-start/tsconfig.json +0 -25
- package/hooks/hook-announce-stop/tsconfig.json +0 -25
- package/hooks/hook-autoformat/tsconfig.json +0 -25
- package/hooks/hook-branchprotect/tsconfig.json +0 -25
- package/hooks/hook-checkbugs/tsconfig.json +0 -15
- package/hooks/hook-checkdocs/tsconfig.json +0 -15
- package/hooks/hook-checkfiles/tsconfig.json +0 -15
- package/hooks/hook-checklint/tsconfig.json +0 -15
- package/hooks/hook-checkpoint/tsconfig.json +0 -25
- package/hooks/hook-checksecurity/tsconfig.json +0 -15
- package/hooks/hook-checktasks/tsconfig.json +0 -20
- package/hooks/hook-checktests/tsconfig.json +0 -15
- package/hooks/hook-conflict-detect/tsconfig.json +0 -25
- package/hooks/hook-contextrefresh/tsconfig.json +0 -25
- package/hooks/hook-desktopnotify/tsconfig.json +0 -25
- package/hooks/hook-dm-inject/tsconfig.json +0 -25
- package/hooks/hook-envsetup/tsconfig.json +0 -25
- package/hooks/hook-failure-to-task/tsconfig.json +0 -25
- package/hooks/hook-filelock/tsconfig.json +0 -25
- package/hooks/hook-fleet-blockers-gate/src/hook.test.ts +0 -302
- package/hooks/hook-fleet-blockers-gate/tsconfig.json +0 -25
- package/hooks/hook-fleet-catchup/src/hook.test.ts +0 -156
- package/hooks/hook-fleet-catchup/tsconfig.json +0 -25
- package/hooks/hook-gitguard/tsconfig.json +0 -25
- package/hooks/hook-knowledge-context/src/hook.test.ts +0 -379
- package/hooks/hook-packageage/tsconfig.json +0 -25
- package/hooks/hook-permissionguard/tsconfig.json +0 -25
- package/hooks/hook-phonenotify/tsconfig.json +0 -25
- package/hooks/hook-precompact/tsconfig.json +0 -25
- package/hooks/hook-protectfiles/tsconfig.json +0 -25
- package/hooks/hook-scanoutput/src/hook.test.ts +0 -217
- package/hooks/hook-spiral-detector/src/hook.test.ts +0 -72
- package/hooks/hook-stylescheck/tsconfig.json +0 -25
- package/hooks/hook-typecheck-gate/tsconfig.json +0 -25
- package/hooks/hook-workspace-repos-guard/src/hook.test.ts +0 -466
- package/hooks/hook-workspace-repos-guard/tsconfig.json +0 -21
- package/hooks/mention-context/src/hook.test.ts +0 -68
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
isReadOnlyTool,
|
|
4
|
-
isSafeArg,
|
|
5
|
-
marksReadState,
|
|
6
|
-
buildDenyReason,
|
|
7
|
-
detectFreeze,
|
|
8
|
-
parseBlockersJson,
|
|
9
|
-
computeFreezeState,
|
|
10
|
-
decide,
|
|
11
|
-
} from "./hook";
|
|
12
|
-
|
|
13
|
-
const NOW = new Date("2026-07-20T00:00:00.000Z");
|
|
14
|
-
|
|
15
|
-
describe("hook-fleet-blockers-gate", () => {
|
|
16
|
-
describe("isReadOnlyTool", () => {
|
|
17
|
-
test("built-in read-only tools pass", () => {
|
|
18
|
-
expect(isReadOnlyTool("Read")).toBe(true);
|
|
19
|
-
expect(isReadOnlyTool("Glob")).toBe(true);
|
|
20
|
-
expect(isReadOnlyTool("Grep")).toBe(true);
|
|
21
|
-
expect(isReadOnlyTool("WebFetch")).toBe(true);
|
|
22
|
-
expect(isReadOnlyTool("WebSearch")).toBe(true);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("mutating built-ins are gated", () => {
|
|
26
|
-
expect(isReadOnlyTool("Bash")).toBe(false);
|
|
27
|
-
expect(isReadOnlyTool("Edit")).toBe(false);
|
|
28
|
-
expect(isReadOnlyTool("Write")).toBe(false);
|
|
29
|
-
expect(isReadOnlyTool("NotebookEdit")).toBe(false);
|
|
30
|
-
expect(isReadOnlyTool("Task")).toBe(false);
|
|
31
|
-
expect(isReadOnlyTool("TodoWrite")).toBe(false);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test("read-style MCP operations pass", () => {
|
|
35
|
-
expect(isReadOnlyTool("mcp__conversations__list_channels")).toBe(true);
|
|
36
|
-
expect(isReadOnlyTool("mcp__conversations__get_blockers")).toBe(true);
|
|
37
|
-
expect(isReadOnlyTool("mcp__todos__search_tools")).toBe(true);
|
|
38
|
-
expect(isReadOnlyTool("mcp__monitor__heartbeat")).toBe(true);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("mutating MCP operations are gated", () => {
|
|
42
|
-
expect(isReadOnlyTool("mcp__conversations__send_message")).toBe(false);
|
|
43
|
-
expect(isReadOnlyTool("mcp__todos__create_task")).toBe(false);
|
|
44
|
-
expect(isReadOnlyTool("mcp__configs__apply_config")).toBe(false);
|
|
45
|
-
expect(isReadOnlyTool("mcp__emails__delete_domain")).toBe(false);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("missing tool name is gated (conservative)", () => {
|
|
49
|
-
expect(isReadOnlyTool(undefined)).toBe(false);
|
|
50
|
-
expect(isReadOnlyTool("")).toBe(false);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("prefix matching does not overmatch (getaway is not get)", () => {
|
|
54
|
-
expect(isReadOnlyTool("mcp__x__getaway_driver")).toBe(false);
|
|
55
|
-
expect(isReadOnlyTool("mcp__x__listen_events")).toBe(false);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// self-lift fix: conversations read_* tools mark messages read, which would
|
|
59
|
-
// clear the blocker's read_at and lift the freeze — so they must be GATED.
|
|
60
|
-
test("conversations read_* tools are GATED (they mark messages read → self-lift)", () => {
|
|
61
|
-
expect(isReadOnlyTool("mcp__conversations__read_messages")).toBe(false);
|
|
62
|
-
expect(isReadOnlyTool("mcp__conversations__read_channel")).toBe(false);
|
|
63
|
-
expect(isReadOnlyTool("mcp__conversations__read_digest")).toBe(false);
|
|
64
|
-
expect(isReadOnlyTool("mcp__conversations__read_thread")).toBe(false);
|
|
65
|
-
expect(isReadOnlyTool("mcp__conversations__read_channel_notifications")).toBe(false);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test("safe conversations orientation tools stay ALLOWED during a freeze", () => {
|
|
69
|
-
expect(isReadOnlyTool("mcp__conversations__get_blockers")).toBe(true);
|
|
70
|
-
expect(isReadOnlyTool("mcp__conversations__get_message")).toBe(true);
|
|
71
|
-
expect(isReadOnlyTool("mcp__conversations__search_messages")).toBe(true);
|
|
72
|
-
expect(isReadOnlyTool("mcp__conversations__list_channels")).toBe(true);
|
|
73
|
-
expect(isReadOnlyTool("mcp__conversations__get_thread_replies")).toBe(true);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("read_* gating is scoped to conversations (other servers' read_ ops unaffected)", () => {
|
|
77
|
-
expect(marksReadState("mcp__conversations__read_messages")).toBe(true);
|
|
78
|
-
expect(marksReadState("mcp__conversations__get_blockers")).toBe(false);
|
|
79
|
-
expect(marksReadState("mcp__files__read_file")).toBe(false);
|
|
80
|
-
// a non-conversations read_ op still passes the generic prefix rule
|
|
81
|
-
expect(isReadOnlyTool("mcp__files__read_file")).toBe(true);
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
describe("buildDenyReason", () => {
|
|
86
|
-
const msg = buildDenyReason("Active blocking=1 blocker from bot: [FREEZE] cutover");
|
|
87
|
-
|
|
88
|
-
test("points at the read-only get_blockers tool", () => {
|
|
89
|
-
expect(msg).toContain("mcp__conversations__get_blockers");
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("warns against the self-lifting read_* tools", () => {
|
|
93
|
-
expect(msg).toContain("read_messages");
|
|
94
|
-
expect(msg).toContain("read_channel");
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("does not tell the agent to run the gated Bash `conversations blockers` command", () => {
|
|
98
|
-
// The phrase must not appear as a bare command instruction (it is the MCP tool that is referenced).
|
|
99
|
-
expect(msg).not.toContain("conversations blockers");
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("carries the underlying blocker reason", () => {
|
|
103
|
-
expect(msg).toContain("Active blocking=1 blocker from bot");
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
describe("isSafeArg (argument-injection guard)", () => {
|
|
108
|
-
test("accepts fleet agent names and email-style ids", () => {
|
|
109
|
-
expect(isSafeArg("lycurgus")).toBe(true);
|
|
110
|
-
expect(isSafeArg("andrei@hasna.com")).toBe(true);
|
|
111
|
-
expect(isSafeArg("agent_07.beta-1")).toBe(true);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("rejects leading dash (flag injection), spaces, shell metachars, empty", () => {
|
|
115
|
-
expect(isSafeArg("--from")).toBe(false);
|
|
116
|
-
expect(isSafeArg("-j")).toBe(false);
|
|
117
|
-
expect(isSafeArg("a b")).toBe(false);
|
|
118
|
-
expect(isSafeArg("a;rm -rf /")).toBe(false);
|
|
119
|
-
expect(isSafeArg("a`whoami`")).toBe(false);
|
|
120
|
-
expect(isSafeArg("$(id)")).toBe(false);
|
|
121
|
-
expect(isSafeArg("")).toBe(false);
|
|
122
|
-
expect(isSafeArg(undefined)).toBe(false);
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
describe("detectFreeze — blocking=1 is the only trigger", () => {
|
|
127
|
-
// blocking=1 -> DENY
|
|
128
|
-
test("a blocking=1 blocker freezes (author- and text-agnostic)", () => {
|
|
129
|
-
const result = detectFreeze([
|
|
130
|
-
{ from_agent: "andrei@hasna.com", content: "[FREEZE] fleet cutover", blocking: true },
|
|
131
|
-
]);
|
|
132
|
-
expect(result.frozen).toBe(true);
|
|
133
|
-
expect(result.reason).toContain("blocking=1");
|
|
134
|
-
expect(result.reason).toContain("andrei@hasna.com");
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test("a blocking=1 blocker with NO freeze text still freezes", () => {
|
|
138
|
-
const result = detectFreeze([
|
|
139
|
-
{ from_agent: "release-bot", content: "migration running, do not deploy", blocking: true },
|
|
140
|
-
]);
|
|
141
|
-
expect(result.frozen).toBe(true);
|
|
142
|
-
expect(result.reason).toContain("release-bot");
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test("a blocking=1 blocker from any author freezes (author is not a security gate)", () => {
|
|
146
|
-
expect(detectFreeze([{ from_agent: "some-agent", content: "hold", blocking: true }]).frozen).toBe(true);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
// freeze TEXT with no blocking=1 -> ALLOW (phantom-freeze bug stays fixed)
|
|
150
|
-
test("[FREEZE] text without a blocking flag does NOT freeze (phantom-freeze fix)", () => {
|
|
151
|
-
expect(
|
|
152
|
-
detectFreeze([{ from_agent: "andrei@hasna.com", content: "[FREEZE] heads up", blocking: false }]).frozen
|
|
153
|
-
).toBe(false);
|
|
154
|
-
expect(
|
|
155
|
-
detectFreeze([{ from_agent: "some-agent", content: "[FREEZE] cutover", blocking: false }]).frozen
|
|
156
|
-
).toBe(false);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test("[UNFREEZE]/severity text is ignored entirely (no text scanning)", () => {
|
|
160
|
-
expect(
|
|
161
|
-
detectFreeze([{ from_agent: "andrei@hasna.com", content: "[UNFREEZE] all clear", blocking: false }]).frozen
|
|
162
|
-
).toBe(false);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// no blockers -> ALLOW
|
|
166
|
-
test("empty list does not freeze", () => {
|
|
167
|
-
expect(detectFreeze([]).frozen).toBe(false);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
test("non-blocking messages do not freeze", () => {
|
|
171
|
-
expect(detectFreeze([{ from_agent: "worker", content: "please review PR #42", blocking: false }]).frozen).toBe(false);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
test("garbage entries are ignored", () => {
|
|
175
|
-
expect(detectFreeze([null, 42, "string", {}]).frozen).toBe(false);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test("blocking flag tolerates numeric/string encodings (1, '1', 'true')", () => {
|
|
179
|
-
expect(detectFreeze([{ from_agent: "x", content: "y", blocking: 1 }]).frozen).toBe(true);
|
|
180
|
-
expect(detectFreeze([{ from_agent: "x", content: "y", blocking: "1" }]).frozen).toBe(true);
|
|
181
|
-
expect(detectFreeze([{ from_agent: "x", content: "y", blocking: "true" }]).frozen).toBe(true);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
test("false-y blocking encodings do not freeze", () => {
|
|
185
|
-
expect(detectFreeze([{ from_agent: "x", content: "y", blocking: 0 }]).frozen).toBe(false);
|
|
186
|
-
expect(detectFreeze([{ from_agent: "x", content: "y", blocking: "false" }]).frozen).toBe(false);
|
|
187
|
-
expect(detectFreeze([{ from_agent: "x", content: "y" }]).frozen).toBe(false);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
// order-independence: a green suite must not be able to hide a "first item only" bug
|
|
191
|
-
test("a blocking=1 blocker anywhere in the list freezes (order-independent)", () => {
|
|
192
|
-
const result = detectFreeze([
|
|
193
|
-
{ from_agent: "a", content: "note 1", blocking: false },
|
|
194
|
-
{ from_agent: "b", content: "note 2", blocking: false },
|
|
195
|
-
{ from_agent: "c", content: "note 3", blocking: false },
|
|
196
|
-
{ from_agent: "d", content: "the real stop", blocking: true },
|
|
197
|
-
]);
|
|
198
|
-
expect(result.frozen).toBe(true);
|
|
199
|
-
expect(result.reason).toContain("the real stop");
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
test("reads the real from_agent schema field for the advisory reason", () => {
|
|
203
|
-
const result = detectFreeze([
|
|
204
|
-
{ from_agent: "coordinator", to_agent: "worker", channel: "announcements", content: "stop", blocking: true },
|
|
205
|
-
]);
|
|
206
|
-
expect(result.reason).toContain("coordinator");
|
|
207
|
-
});
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
describe("parseBlockersJson", () => {
|
|
211
|
-
test("parses a bare array", () => {
|
|
212
|
-
expect(parseBlockersJson('[{"content":"x"}]')).toHaveLength(1);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
test("parses wrapped blockers", () => {
|
|
216
|
-
expect(parseBlockersJson('{"blockers":[{"content":"x"}]}')).toHaveLength(1);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
test("parses wrapped messages", () => {
|
|
220
|
-
expect(parseBlockersJson('{"messages":[{"content":"x"}]}')).toHaveLength(1);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
test("returns empty on invalid JSON", () => {
|
|
224
|
-
expect(parseBlockersJson("not json")).toHaveLength(0);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
test("returns empty on null/empty", () => {
|
|
228
|
-
expect(parseBlockersJson(null)).toHaveLength(0);
|
|
229
|
-
expect(parseBlockersJson("")).toHaveLength(0);
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
describe("computeFreezeState — fail-open + verified", () => {
|
|
234
|
-
// comms error -> ALLOW (fail-open, unverified)
|
|
235
|
-
test("runner error → fail-open (not frozen, unverified)", () => {
|
|
236
|
-
const e = computeFreezeState(NOW, () => {
|
|
237
|
-
throw new Error("conversations CLI missing / timeout");
|
|
238
|
-
});
|
|
239
|
-
expect(e.state.frozen).toBe(false);
|
|
240
|
-
expect(e.verified).toBe(false);
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
// no blockers -> ALLOW (verified)
|
|
244
|
-
test("empty blockers → not frozen, verified", () => {
|
|
245
|
-
const e = computeFreezeState(NOW, () => "[]");
|
|
246
|
-
expect(e.state.frozen).toBe(false);
|
|
247
|
-
expect(e.verified).toBe(true);
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
// blocking=1 -> DENY
|
|
251
|
-
test("blocking=1 in CLI output → frozen, verified", () => {
|
|
252
|
-
const raw = JSON.stringify([{ from_agent: "bot", content: "deploy freeze", blocking: true }]);
|
|
253
|
-
const e = computeFreezeState(NOW, () => raw);
|
|
254
|
-
expect(e.state.frozen).toBe(true);
|
|
255
|
-
expect(e.verified).toBe(true);
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
test("parses the real wrapped shape and stays order-independent", () => {
|
|
259
|
-
const raw = JSON.stringify({
|
|
260
|
-
blockers: [
|
|
261
|
-
{ from_agent: "a", content: "x", blocking: false },
|
|
262
|
-
{ from_agent: "b", content: "stop now", blocking: true },
|
|
263
|
-
],
|
|
264
|
-
});
|
|
265
|
-
const e = computeFreezeState(NOW, () => raw);
|
|
266
|
-
expect(e.state.frozen).toBe(true);
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
test("malformed CLI output → fail-open (not frozen) but verified", () => {
|
|
270
|
-
const e = computeFreezeState(NOW, () => "not-json-at-all");
|
|
271
|
-
expect(e.state.frozen).toBe(false);
|
|
272
|
-
expect(e.verified).toBe(true);
|
|
273
|
-
});
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
describe("decide — permission gate", () => {
|
|
277
|
-
const frozen = { frozen: true, reason: "Active blocking=1 blocker from bot: x" };
|
|
278
|
-
const clear = { frozen: false, reason: "" };
|
|
279
|
-
|
|
280
|
-
// HOOKS_FLEET_GATE_DISABLE=1 -> ALLOW
|
|
281
|
-
test("kill switch (disabled) → allow even under a freeze", () => {
|
|
282
|
-
expect(decide({ disabled: true, toolName: "Bash", freeze: frozen }).allow).toBe(true);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
// read-only tool under a blocker -> ALLOW
|
|
286
|
-
test("read-only tool under an active freeze → allow", () => {
|
|
287
|
-
expect(decide({ disabled: false, toolName: "Read", freeze: frozen }).allow).toBe(true);
|
|
288
|
-
expect(decide({ disabled: false, toolName: "mcp__conversations__get_blockers", freeze: frozen }).allow).toBe(true);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
// blocking=1 + mutating -> DENY
|
|
292
|
-
test("mutating tool under an active freeze → deny (with reason)", () => {
|
|
293
|
-
const d = decide({ disabled: false, toolName: "Bash", freeze: frozen });
|
|
294
|
-
expect(d.allow).toBe(false);
|
|
295
|
-
expect(d.reason).toContain("blocking=1");
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
test("mutating tool with no freeze → allow", () => {
|
|
299
|
-
expect(decide({ disabled: false, toolName: "Bash", freeze: clear }).allow).toBe(true);
|
|
300
|
-
});
|
|
301
|
-
});
|
|
302
|
-
});
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ESNext",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"lib": ["ESNext"],
|
|
6
|
-
"moduleResolution": "bundler",
|
|
7
|
-
"allowImportingTsExtensions": true,
|
|
8
|
-
"strict": true,
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"skipLibCheck": true,
|
|
11
|
-
"forceConsistentCasingInFileNames": true,
|
|
12
|
-
"resolveJsonModule": true,
|
|
13
|
-
"isolatedModules": true,
|
|
14
|
-
"noEmit": true,
|
|
15
|
-
"noUnusedLocals": true,
|
|
16
|
-
"noUnusedParameters": true,
|
|
17
|
-
"declaration": true,
|
|
18
|
-
"declarationMap": true,
|
|
19
|
-
"outDir": "./dist",
|
|
20
|
-
"rootDir": "./src",
|
|
21
|
-
"types": ["bun-types"]
|
|
22
|
-
},
|
|
23
|
-
"include": ["src/**/*"],
|
|
24
|
-
"exclude": ["node_modules", "dist"]
|
|
25
|
-
}
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
parseJsonList,
|
|
4
|
-
hasJsonList,
|
|
5
|
-
resolveSince,
|
|
6
|
-
formatCatchup,
|
|
7
|
-
truncate,
|
|
8
|
-
safeIdentifier,
|
|
9
|
-
safeSince,
|
|
10
|
-
} from "./hook";
|
|
11
|
-
|
|
12
|
-
describe("hook-fleet-catchup", () => {
|
|
13
|
-
describe("parseJsonList", () => {
|
|
14
|
-
test("parses a bare array", () => {
|
|
15
|
-
expect(parseJsonList('[1,2]')).toEqual([1, 2]);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
test("unwraps named keys in order", () => {
|
|
19
|
-
expect(parseJsonList('{"blockers":[1]}', "blockers", "messages")).toEqual([1]);
|
|
20
|
-
expect(parseJsonList('{"messages":[2]}', "blockers", "messages")).toEqual([2]);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test("returns empty for garbage and null", () => {
|
|
24
|
-
expect(parseJsonList("nope")).toEqual([]);
|
|
25
|
-
expect(parseJsonList(null)).toEqual([]);
|
|
26
|
-
expect(parseJsonList('{"other":true}')).toEqual([]);
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe("hasJsonList", () => {
|
|
31
|
-
test("accepts bare arrays and configured array envelopes", () => {
|
|
32
|
-
expect(hasJsonList("[]", "notifications", "messages")).toBe(true);
|
|
33
|
-
expect(hasJsonList('{"notifications":[]}', "notifications", "messages")).toBe(true);
|
|
34
|
-
expect(hasJsonList('{"messages":[1]}', "notifications", "messages")).toBe(true);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("rejects invalid JSON and unexpected envelopes", () => {
|
|
38
|
-
expect(hasJsonList(null, "notifications", "messages")).toBe(false);
|
|
39
|
-
expect(hasJsonList("not json", "notifications", "messages")).toBe(false);
|
|
40
|
-
expect(hasJsonList('{"other":[]}', "notifications", "messages")).toBe(false);
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe("resolveSince", () => {
|
|
45
|
-
const now = new Date("2026-07-06T12:00:00.000Z");
|
|
46
|
-
|
|
47
|
-
test("uses the stored timestamp when valid and in the past", () => {
|
|
48
|
-
expect(resolveSince(now, "2026-07-06T09:30:00.000Z")).toBe("2026-07-06T09:30:00.000Z");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("falls back to 24h lookback when state is missing", () => {
|
|
52
|
-
expect(resolveSince(now, null)).toBe("2026-07-05T12:00:00.000Z");
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("falls back when state is garbage", () => {
|
|
56
|
-
expect(resolveSince(now, "not-a-date")).toBe("2026-07-05T12:00:00.000Z");
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("falls back when state is in the future (clock skew)", () => {
|
|
60
|
-
expect(resolveSince(now, "2027-01-01T00:00:00.000Z")).toBe("2026-07-05T12:00:00.000Z");
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
describe("formatCatchup", () => {
|
|
65
|
-
test("returns null when everything is empty", () => {
|
|
66
|
-
expect(formatCatchup([], [], null)).toBeNull();
|
|
67
|
-
expect(formatCatchup([], [], '{"messages":[]}')).toBeNull();
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("includes blockers section with read-duty reminder", () => {
|
|
71
|
-
const out = formatCatchup(
|
|
72
|
-
[{ from: "chief", content: "[FREEZE] hold publishes", created_at: "2026-07-06T10:00:00Z" }],
|
|
73
|
-
[],
|
|
74
|
-
null
|
|
75
|
-
);
|
|
76
|
-
expect(out).toContain("UNREAD BLOCKING MESSAGES (1)");
|
|
77
|
-
expect(out).toContain("[FREEZE] hold publishes");
|
|
78
|
-
expect(out).toContain("stop and escalate");
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("includes notifications and digest sections", () => {
|
|
82
|
-
const out = formatCatchup(
|
|
83
|
-
[],
|
|
84
|
-
[{ from: "bot", channel: "git-releases", preview: "released x@1.0.0" }],
|
|
85
|
-
JSON.stringify({ messages: [{ from: "chief", content: "[POLICY] HACP v1.1" }] })
|
|
86
|
-
);
|
|
87
|
-
expect(out).toContain("CHANNEL NOTIFICATIONS since last catchup (1)");
|
|
88
|
-
expect(out).toContain("#git-releases");
|
|
89
|
-
expect(out).toContain("ANNOUNCEMENTS digest");
|
|
90
|
-
expect(out).toContain("[POLICY] HACP v1.1");
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("skips digest section when digest JSON is invalid", () => {
|
|
94
|
-
const out = formatCatchup([{ content: "x" }], [], "garbage");
|
|
95
|
-
expect(out).toContain("UNREAD BLOCKING MESSAGES");
|
|
96
|
-
expect(out).not.toContain("ANNOUNCEMENTS digest");
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("caps listed blockers at 10", () => {
|
|
100
|
-
const blockers = Array.from({ length: 25 }, (_, i) => ({ content: `blocker-${i}` }));
|
|
101
|
-
const out = formatCatchup(blockers, [], null)!;
|
|
102
|
-
expect(out).toContain("UNREAD BLOCKING MESSAGES (25)");
|
|
103
|
-
expect(out).toContain("blocker-9");
|
|
104
|
-
expect(out).not.toContain("blocker-10");
|
|
105
|
-
expect(out.split("\n").filter((l) => l.startsWith("- ")).length).toBe(10);
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
describe("truncate", () => {
|
|
110
|
-
test("passes short strings through", () => {
|
|
111
|
-
expect(truncate("abc", 10)).toBe("abc");
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("truncates long strings with ellipsis", () => {
|
|
115
|
-
const out = truncate("a".repeat(50), 10);
|
|
116
|
-
expect(out.length).toBe(10);
|
|
117
|
-
expect(out.endsWith("…")).toBe(true);
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
describe("safeIdentifier", () => {
|
|
122
|
-
test("accepts normal agent names", () => {
|
|
123
|
-
expect(safeIdentifier("chief")).toBe("chief");
|
|
124
|
-
expect(safeIdentifier("build-hooks_01.a")).toBe("build-hooks_01.a");
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test("rejects shell metacharacters", () => {
|
|
128
|
-
expect(safeIdentifier("$(rm -rf /)")).toBeNull();
|
|
129
|
-
expect(safeIdentifier("a;b")).toBeNull();
|
|
130
|
-
expect(safeIdentifier("a b")).toBeNull();
|
|
131
|
-
expect(safeIdentifier("`x`")).toBeNull();
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test("rejects empty/undefined", () => {
|
|
135
|
-
expect(safeIdentifier(undefined)).toBeNull();
|
|
136
|
-
expect(safeIdentifier("")).toBeNull();
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
describe("safeSince", () => {
|
|
141
|
-
test("accepts relative durations", () => {
|
|
142
|
-
expect(safeSince("7d", "1d")).toBe("7d");
|
|
143
|
-
expect(safeSince("24h", "1d")).toBe("24h");
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("accepts ISO timestamps", () => {
|
|
147
|
-
expect(safeSince("2026-07-06T00:00:00Z", "1d")).toBe("2026-07-06T00:00:00Z");
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test("falls back on anything else", () => {
|
|
151
|
-
expect(safeSince("; rm -rf /", "7d")).toBe("7d");
|
|
152
|
-
expect(safeSince("$(x)", "7d")).toBe("7d");
|
|
153
|
-
expect(safeSince(undefined, "7d")).toBe("7d");
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
});
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ESNext",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"lib": ["ESNext"],
|
|
6
|
-
"moduleResolution": "bundler",
|
|
7
|
-
"allowImportingTsExtensions": true,
|
|
8
|
-
"strict": true,
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"skipLibCheck": true,
|
|
11
|
-
"forceConsistentCasingInFileNames": true,
|
|
12
|
-
"resolveJsonModule": true,
|
|
13
|
-
"isolatedModules": true,
|
|
14
|
-
"noEmit": true,
|
|
15
|
-
"noUnusedLocals": true,
|
|
16
|
-
"noUnusedParameters": true,
|
|
17
|
-
"declaration": true,
|
|
18
|
-
"declarationMap": true,
|
|
19
|
-
"outDir": "./dist",
|
|
20
|
-
"rootDir": "./src",
|
|
21
|
-
"types": ["bun-types"]
|
|
22
|
-
},
|
|
23
|
-
"include": ["src/**/*"],
|
|
24
|
-
"exclude": ["node_modules", "dist"]
|
|
25
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ESNext",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"lib": ["ESNext"],
|
|
6
|
-
"moduleResolution": "bundler",
|
|
7
|
-
"allowImportingTsExtensions": true,
|
|
8
|
-
"strict": true,
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"skipLibCheck": true,
|
|
11
|
-
"forceConsistentCasingInFileNames": true,
|
|
12
|
-
"resolveJsonModule": true,
|
|
13
|
-
"isolatedModules": true,
|
|
14
|
-
"noEmit": true,
|
|
15
|
-
"noUnusedLocals": true,
|
|
16
|
-
"noUnusedParameters": true,
|
|
17
|
-
"declaration": true,
|
|
18
|
-
"declarationMap": true,
|
|
19
|
-
"outDir": "./dist",
|
|
20
|
-
"rootDir": "./src",
|
|
21
|
-
"types": ["bun-types"]
|
|
22
|
-
},
|
|
23
|
-
"include": ["src/**/*"],
|
|
24
|
-
"exclude": ["node_modules", "dist"]
|
|
25
|
-
}
|