@ricky-stevens/context-guardian 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +29 -0
- package/.claude-plugin/plugin.json +63 -0
- package/.github/workflows/ci.yml +66 -0
- package/CLAUDE.md +132 -0
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/biome.json +34 -0
- package/bun.lock +31 -0
- package/hooks/precompact.mjs +73 -0
- package/hooks/session-start.mjs +133 -0
- package/hooks/stop.mjs +172 -0
- package/hooks/submit.mjs +133 -0
- package/lib/checkpoint.mjs +258 -0
- package/lib/compact-cli.mjs +124 -0
- package/lib/compact-output.mjs +350 -0
- package/lib/config.mjs +40 -0
- package/lib/content.mjs +33 -0
- package/lib/diagnostics.mjs +221 -0
- package/lib/estimate.mjs +254 -0
- package/lib/extract-helpers.mjs +869 -0
- package/lib/handoff.mjs +329 -0
- package/lib/logger.mjs +34 -0
- package/lib/mcp-tools.mjs +200 -0
- package/lib/paths.mjs +90 -0
- package/lib/stats.mjs +81 -0
- package/lib/statusline.mjs +123 -0
- package/lib/synthetic-session.mjs +273 -0
- package/lib/tokens.mjs +170 -0
- package/lib/tool-summary.mjs +399 -0
- package/lib/transcript.mjs +939 -0
- package/lib/trim.mjs +158 -0
- package/package.json +22 -0
- package/skills/compact/SKILL.md +20 -0
- package/skills/config/SKILL.md +70 -0
- package/skills/handoff/SKILL.md +26 -0
- package/skills/prune/SKILL.md +20 -0
- package/skills/stats/SKILL.md +100 -0
- package/sonar-project.properties +12 -0
- package/test/checkpoint.test.mjs +171 -0
- package/test/compact-cli.test.mjs +230 -0
- package/test/compact-output.test.mjs +284 -0
- package/test/compaction-e2e.test.mjs +809 -0
- package/test/content.test.mjs +86 -0
- package/test/diagnostics.test.mjs +188 -0
- package/test/edge-cases.test.mjs +543 -0
- package/test/estimate.test.mjs +262 -0
- package/test/extract-helpers-coverage.test.mjs +333 -0
- package/test/extract-helpers.test.mjs +234 -0
- package/test/handoff.test.mjs +738 -0
- package/test/integration.test.mjs +582 -0
- package/test/logger.test.mjs +70 -0
- package/test/manual-compaction-test.md +426 -0
- package/test/mcp-tools.test.mjs +443 -0
- package/test/paths.test.mjs +250 -0
- package/test/quick-compaction-test.md +191 -0
- package/test/stats.test.mjs +88 -0
- package/test/statusline.test.mjs +222 -0
- package/test/submit.test.mjs +232 -0
- package/test/synthetic-session.test.mjs +600 -0
- package/test/tokens.test.mjs +293 -0
- package/test/tool-summary.test.mjs +771 -0
- package/test/transcript-coverage.test.mjs +369 -0
- package/test/transcript.test.mjs +596 -0
- package/test/trim.test.mjs +356 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
isAffirmativeConfirmation,
|
|
5
|
+
isErrorResponse,
|
|
6
|
+
isShortErrorResponse,
|
|
7
|
+
isSystemInjection,
|
|
8
|
+
startEndTrim,
|
|
9
|
+
} from "../lib/trim.mjs";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// startEndTrim
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
describe("startEndTrim", () => {
|
|
16
|
+
it("returns content unchanged if under limit", () => {
|
|
17
|
+
assert.equal(startEndTrim("hello world", 100), "hello world");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns empty string for null", () => {
|
|
21
|
+
assert.equal(startEndTrim(null, 100), "");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns empty string for undefined", () => {
|
|
25
|
+
assert.equal(startEndTrim(undefined, 100), "");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns empty string for empty string", () => {
|
|
29
|
+
assert.equal(startEndTrim("", 100), "");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("trims middle and keeps start+end when over limit", () => {
|
|
33
|
+
const content = "A".repeat(50) + "B".repeat(50);
|
|
34
|
+
const result = startEndTrim(content, 20);
|
|
35
|
+
assert.ok(result.startsWith("A".repeat(10)));
|
|
36
|
+
assert.ok(result.endsWith("B".repeat(10)));
|
|
37
|
+
assert.ok(result.includes("[..."));
|
|
38
|
+
assert.ok(result.includes("trimmed from middle..."));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("includes the trimmed char count in the marker", () => {
|
|
42
|
+
const content = "X".repeat(100);
|
|
43
|
+
const result = startEndTrim(content, 20);
|
|
44
|
+
// keep 10 start + 10 end = 80 trimmed
|
|
45
|
+
assert.ok(result.includes("...80 chars trimmed from middle..."));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("respects custom keepStart/keepEnd params", () => {
|
|
49
|
+
const content = "A".repeat(30) + "B".repeat(70);
|
|
50
|
+
const result = startEndTrim(content, 20, 5, 15);
|
|
51
|
+
assert.ok(result.startsWith("A".repeat(5)));
|
|
52
|
+
assert.ok(result.endsWith("B".repeat(15)));
|
|
53
|
+
// 100 - 5 - 15 = 80 trimmed
|
|
54
|
+
assert.ok(result.includes("...80 chars trimmed from middle..."));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns content unchanged for exact-length content", () => {
|
|
58
|
+
const content = "Z".repeat(50);
|
|
59
|
+
assert.equal(startEndTrim(content, 50), content);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// isErrorResponse
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe("isErrorResponse", () => {
|
|
68
|
+
it("returns false for null", () => {
|
|
69
|
+
assert.equal(isErrorResponse(null), false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns false for undefined", () => {
|
|
73
|
+
assert.equal(isErrorResponse(undefined), false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns false for empty string", () => {
|
|
77
|
+
assert.equal(isErrorResponse(""), false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns false for non-string", () => {
|
|
81
|
+
assert.equal(isErrorResponse(42), false);
|
|
82
|
+
assert.equal(isErrorResponse({}), false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const errorStrings = [
|
|
86
|
+
["error", "Something went error in the build"],
|
|
87
|
+
["Error", "Error: file not found"],
|
|
88
|
+
["ERROR", "FATAL ERROR occurred"],
|
|
89
|
+
["failed", "Build failed with 3 warnings"],
|
|
90
|
+
["FAILED", "Test FAILED"],
|
|
91
|
+
["not found", "Module not found"],
|
|
92
|
+
["permission denied", "permission denied for /etc/shadow"],
|
|
93
|
+
["ENOENT", "ENOENT: no such file or directory"],
|
|
94
|
+
["EACCES", "EACCES: permission denied"],
|
|
95
|
+
["exit code 1", "Process exited with exit code 1"],
|
|
96
|
+
["exception", "Unhandled exception in worker"],
|
|
97
|
+
["timeout", "Connection timeout after 30s"],
|
|
98
|
+
["does not exist", "The file does not exist"],
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
for (const [pattern, example] of errorStrings) {
|
|
102
|
+
it(`returns true for string containing "${pattern}"`, () => {
|
|
103
|
+
assert.equal(isErrorResponse(example), true);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it("returns false for normal content without error patterns", () => {
|
|
108
|
+
assert.equal(isErrorResponse("const x = 42;"), false);
|
|
109
|
+
assert.equal(isErrorResponse("function hello() { return 1; }"), false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns true for content containing 'error' even in identifiers (broad check)", () => {
|
|
113
|
+
// isErrorResponse uses word-boundary \b, so "errorHandler" won't match
|
|
114
|
+
// because there's no word boundary between "error" and "Handler"
|
|
115
|
+
// Actually \berror\b requires boundaries on BOTH sides
|
|
116
|
+
assert.equal(isErrorResponse("const errorHandler = function() {}"), false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns true when error is a standalone word", () => {
|
|
120
|
+
assert.equal(isErrorResponse("caught an error here"), true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// isShortErrorResponse
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
describe("isShortErrorResponse", () => {
|
|
129
|
+
it("returns false for null", () => {
|
|
130
|
+
assert.equal(isShortErrorResponse(null), false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns false for undefined", () => {
|
|
134
|
+
assert.equal(isShortErrorResponse(undefined), false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("returns false for empty string", () => {
|
|
138
|
+
assert.equal(isShortErrorResponse(""), false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns true for short error-like strings", () => {
|
|
142
|
+
assert.equal(isShortErrorResponse("Error: file not found"), true);
|
|
143
|
+
assert.equal(
|
|
144
|
+
isShortErrorResponse("ENOENT: no such file or directory"),
|
|
145
|
+
true,
|
|
146
|
+
);
|
|
147
|
+
assert.equal(isShortErrorResponse("Build failed"), true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns false for long strings even with error patterns", () => {
|
|
151
|
+
const longContent = `This file has an error in it.\n${"x".repeat(1000)}`;
|
|
152
|
+
assert.equal(isShortErrorResponse(longContent), false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("returns false for short strings without error patterns", () => {
|
|
156
|
+
assert.equal(isShortErrorResponse("all good here"), false);
|
|
157
|
+
assert.equal(isShortErrorResponse("const x = 42;"), false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns false for exactly 500-char string with error pattern", () => {
|
|
161
|
+
// content.length < 500, so exactly 500 returns false
|
|
162
|
+
const content = `error ${"x".repeat(494)}`;
|
|
163
|
+
assert.equal(content.length, 500);
|
|
164
|
+
assert.equal(isShortErrorResponse(content), false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("returns true for 499-char string with error pattern", () => {
|
|
168
|
+
const content = `error ${"x".repeat(493)}`;
|
|
169
|
+
assert.equal(content.length, 499);
|
|
170
|
+
assert.equal(isShortErrorResponse(content), true);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// isAffirmativeConfirmation
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
describe("isAffirmativeConfirmation", () => {
|
|
179
|
+
it("returns false for null", () => {
|
|
180
|
+
assert.equal(isAffirmativeConfirmation(null), false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns false for undefined", () => {
|
|
184
|
+
assert.equal(isAffirmativeConfirmation(undefined), false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("returns false for empty string", () => {
|
|
188
|
+
assert.equal(isAffirmativeConfirmation(""), false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("returns false for non-string", () => {
|
|
192
|
+
assert.equal(isAffirmativeConfirmation(42), false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const affirmatives = [
|
|
196
|
+
"yes",
|
|
197
|
+
"Yes",
|
|
198
|
+
"YES",
|
|
199
|
+
"y",
|
|
200
|
+
"ok",
|
|
201
|
+
"okay",
|
|
202
|
+
"sure",
|
|
203
|
+
"go ahead",
|
|
204
|
+
"continue",
|
|
205
|
+
"proceed",
|
|
206
|
+
"do it",
|
|
207
|
+
"correct",
|
|
208
|
+
"right",
|
|
209
|
+
"exactly",
|
|
210
|
+
"thanks",
|
|
211
|
+
"thank you",
|
|
212
|
+
"yep",
|
|
213
|
+
"yea",
|
|
214
|
+
"yeah",
|
|
215
|
+
"sounds good",
|
|
216
|
+
"lgtm",
|
|
217
|
+
"ship it",
|
|
218
|
+
"please",
|
|
219
|
+
"agreed",
|
|
220
|
+
"go for it",
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
for (const word of affirmatives) {
|
|
224
|
+
it(`returns true for "${word}"`, () => {
|
|
225
|
+
assert.equal(isAffirmativeConfirmation(word), true);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
it("returns true with trailing period", () => {
|
|
230
|
+
assert.equal(isAffirmativeConfirmation("yes."), true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("returns true with trailing exclamation", () => {
|
|
234
|
+
assert.equal(isAffirmativeConfirmation("ok!"), true);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("returns true with trailing comma", () => {
|
|
238
|
+
assert.equal(isAffirmativeConfirmation("sure,"), true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("returns true with multiple trailing punctuation", () => {
|
|
242
|
+
assert.equal(isAffirmativeConfirmation("yes!!!"), true);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("returns false for 'no'", () => {
|
|
246
|
+
assert.equal(isAffirmativeConfirmation("no"), false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("returns false for 'n'", () => {
|
|
250
|
+
assert.equal(isAffirmativeConfirmation("n"), false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("returns false for bare numbers", () => {
|
|
254
|
+
assert.equal(isAffirmativeConfirmation("1"), false);
|
|
255
|
+
assert.equal(isAffirmativeConfirmation("2"), false);
|
|
256
|
+
assert.equal(isAffirmativeConfirmation("3"), false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("returns false for 'not sure'", () => {
|
|
260
|
+
assert.equal(isAffirmativeConfirmation("not sure"), false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("returns false for multi-word messages with substance", () => {
|
|
264
|
+
assert.equal(
|
|
265
|
+
isAffirmativeConfirmation("yes please do the refactoring"),
|
|
266
|
+
false,
|
|
267
|
+
);
|
|
268
|
+
assert.equal(isAffirmativeConfirmation("yes but also..."), false);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// isSystemInjection
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
describe("isSystemInjection", () => {
|
|
277
|
+
it("returns false for null", () => {
|
|
278
|
+
assert.equal(isSystemInjection(null), false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("returns false for undefined", () => {
|
|
282
|
+
assert.equal(isSystemInjection(undefined), false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("returns false for empty string", () => {
|
|
286
|
+
assert.equal(isSystemInjection(""), false);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("returns true for real CG checkpoint with label and Created line", () => {
|
|
290
|
+
assert.equal(
|
|
291
|
+
isSystemInjection(
|
|
292
|
+
"# Context Checkpoint (Smart Compact)\n> Created: 2025-01-01T00:00:00.000Z\n\n## Session State",
|
|
293
|
+
),
|
|
294
|
+
true,
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("returns true for Keep Recent checkpoint", () => {
|
|
299
|
+
assert.equal(
|
|
300
|
+
isSystemInjection(
|
|
301
|
+
"# Context Checkpoint (Keep Recent)\n> Created: 2025-06-01T12:00:00.000Z\n\nContent here",
|
|
302
|
+
),
|
|
303
|
+
true,
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("returns false for user text that starts with Context Checkpoint", () => {
|
|
308
|
+
assert.equal(
|
|
309
|
+
isSystemInjection("# Context Checkpoint\nMy notes about checkpoints"),
|
|
310
|
+
false,
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("returns true for text containing <prior_conversation_history>", () => {
|
|
315
|
+
assert.equal(
|
|
316
|
+
isSystemInjection(
|
|
317
|
+
"Here is the <prior_conversation_history> from before.",
|
|
318
|
+
),
|
|
319
|
+
true,
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("returns true for text containing both SKILL.md and plugin", () => {
|
|
324
|
+
assert.equal(
|
|
325
|
+
isSystemInjection("Loading SKILL.md from the plugin directory for cg"),
|
|
326
|
+
true,
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("returns false for normal user messages", () => {
|
|
331
|
+
assert.equal(isSystemInjection("Please fix the bug in line 42"), false);
|
|
332
|
+
assert.equal(isSystemInjection("How do I use this function?"), false);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("returns false for long markdown without injection patterns", () => {
|
|
336
|
+
const markdown =
|
|
337
|
+
"# My Document\n\nThis is a long document with lots of content.\n".repeat(
|
|
338
|
+
50,
|
|
339
|
+
);
|
|
340
|
+
assert.equal(isSystemInjection(markdown), false);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("returns false for messages containing only SKILL.md without plugin", () => {
|
|
344
|
+
assert.equal(
|
|
345
|
+
isSystemInjection("Check the SKILL.md file for instructions"),
|
|
346
|
+
false,
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("returns false for messages containing only plugin without SKILL.md", () => {
|
|
351
|
+
assert.equal(
|
|
352
|
+
isSystemInjection("Install the plugin from the marketplace"),
|
|
353
|
+
false,
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
});
|