xtrm-cli 2.1.19 → 2.1.28
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/dist/index.cjs +4 -2
- package/dist/index.cjs.map +1 -1
- package/extensions/beads.ts +96 -0
- package/extensions/core/adapter.ts +45 -0
- package/extensions/core/lib.ts +3 -0
- package/extensions/core/logger.ts +45 -0
- package/extensions/core/runner.ts +71 -0
- package/extensions/main-guard-post-push.ts +44 -0
- package/extensions/main-guard.ts +126 -0
- package/extensions/quality-gates.ts +67 -0
- package/extensions/service-skills.ts +88 -0
- package/extensions/xtrm-loader.ts +89 -0
- package/package.json +1 -1
- package/src/commands/install-project.ts +97 -1
- package/src/index.ts +2 -0
- package/test/extensions/beads.test.ts +166 -0
- package/test/extensions/extension-harness.ts +85 -0
- package/test/extensions/main-guard.test.ts +77 -0
- package/test/extensions/quality-gates.test.ts +79 -0
- package/test/extensions/xtrm-loader.test.ts +53 -0
|
@@ -139,6 +139,94 @@ export async function getAvailableProjectSkills(): Promise<string[]> {
|
|
|
139
139
|
* Deep merge settings.json hooks without overwriting existing user hooks.
|
|
140
140
|
* Appends new hooks to existing events intelligently.
|
|
141
141
|
*/
|
|
142
|
+
/**
|
|
143
|
+
* Extract script filename from a hook command.
|
|
144
|
+
*/
|
|
145
|
+
function getScriptFilename(hook: any): string | null {
|
|
146
|
+
const cmd = hook.command || hook.hooks?.[0]?.command || '';
|
|
147
|
+
if (typeof cmd !== 'string') return null;
|
|
148
|
+
// Match script filename including subdirectory (e.g., "gitnexus/gitnexus-hook.cjs")
|
|
149
|
+
const m = cmd.match(/\/hooks\/([A-Za-z0-9_/-]+\.(?:py|cjs|mjs|js))/);
|
|
150
|
+
if (m) return m[1];
|
|
151
|
+
const m2 = cmd.match(/([A-Za-z0-9_-]+\.(?:py|cjs|mjs|js))(?!.*[A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))/);
|
|
152
|
+
return m2?.[1] ?? null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Prune hooks from settings.json that are NOT in the canonical config.
|
|
157
|
+
* This removes stale entries from old versions before merging new ones.
|
|
158
|
+
*
|
|
159
|
+
* @param existing Current settings.json hooks
|
|
160
|
+
* @param canonical Canonical hooks config from hooks.json
|
|
161
|
+
* @returns Pruned settings with stale hooks removed
|
|
162
|
+
*/
|
|
163
|
+
export function pruneStaleHooks(
|
|
164
|
+
existing: Record<string, any>,
|
|
165
|
+
canonical: Record<string, any>,
|
|
166
|
+
): { result: Record<string, any>; removed: string[] } {
|
|
167
|
+
const result = { ...existing };
|
|
168
|
+
const removed: string[] = [];
|
|
169
|
+
|
|
170
|
+
if (!result.hooks || typeof result.hooks !== 'object') {
|
|
171
|
+
return { result, removed };
|
|
172
|
+
}
|
|
173
|
+
if (!canonical.hooks || typeof canonical.hooks !== 'object') {
|
|
174
|
+
return { result, removed };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Collect all canonical script filenames
|
|
178
|
+
const canonicalScripts = new Set<string>();
|
|
179
|
+
for (const [event, hooks] of Object.entries(canonical.hooks)) {
|
|
180
|
+
const hookList = Array.isArray(hooks) ? hooks : [hooks];
|
|
181
|
+
for (const wrapper of hookList) {
|
|
182
|
+
const innerHooks = wrapper.hooks || [wrapper];
|
|
183
|
+
for (const hook of innerHooks) {
|
|
184
|
+
const script = getScriptFilename(hook);
|
|
185
|
+
if (script) canonicalScripts.add(script);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Prune existing hooks not in canonical
|
|
191
|
+
for (const [event, hooks] of Object.entries(result.hooks)) {
|
|
192
|
+
if (!Array.isArray(hooks)) continue;
|
|
193
|
+
|
|
194
|
+
const prunedWrappers: any[] = [];
|
|
195
|
+
for (const wrapper of hooks) {
|
|
196
|
+
const innerHooks = wrapper.hooks || [wrapper];
|
|
197
|
+
const keptInner: any[] = [];
|
|
198
|
+
|
|
199
|
+
for (const hook of innerHooks) {
|
|
200
|
+
const script = getScriptFilename(hook);
|
|
201
|
+
// Keep if: no script (not a file-based hook) OR script is canonical
|
|
202
|
+
if (!script || canonicalScripts.has(script)) {
|
|
203
|
+
keptInner.push(hook);
|
|
204
|
+
} else {
|
|
205
|
+
removed.push(`${event}:${script}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (keptInner.length > 0) {
|
|
210
|
+
if (wrapper.hooks) {
|
|
211
|
+
prunedWrappers.push({ ...wrapper, hooks: keptInner });
|
|
212
|
+
} else if (keptInner.length === 1) {
|
|
213
|
+
prunedWrappers.push(keptInner[0]);
|
|
214
|
+
} else {
|
|
215
|
+
prunedWrappers.push({ ...wrapper, hooks: keptInner });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (prunedWrappers.length > 0) {
|
|
221
|
+
result.hooks[event] = prunedWrappers;
|
|
222
|
+
} else {
|
|
223
|
+
delete result.hooks[event];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { result, removed };
|
|
228
|
+
}
|
|
229
|
+
|
|
142
230
|
export function deepMergeHooks(existing: Record<string, any>, incoming: Record<string, any>): Record<string, any> {
|
|
143
231
|
const result = { ...existing };
|
|
144
232
|
|
|
@@ -266,7 +354,15 @@ export async function installProjectSkill(toolName: string, projectRootOverride?
|
|
|
266
354
|
}
|
|
267
355
|
|
|
268
356
|
const incomingSettings = JSON.parse(await fs.readFile(skillSettingsPath, 'utf8'));
|
|
269
|
-
|
|
357
|
+
|
|
358
|
+
// First prune stale hooks not in canonical config
|
|
359
|
+
const { result: prunedSettings, removed } = pruneStaleHooks(existingSettings, incomingSettings);
|
|
360
|
+
if (removed.length > 0) {
|
|
361
|
+
console.log(kleur.yellow(` ↳ Pruned ${removed.length} stale hook(s): ${removed.join(', ')}`));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Then merge canonical hooks
|
|
365
|
+
const mergedSettings = deepMergeHooks(prunedSettings, incomingSettings);
|
|
270
366
|
|
|
271
367
|
await fs.writeFile(targetSettingsPath, JSON.stringify(mergedSettings, null, 2) + '\n');
|
|
272
368
|
console.log(`${kleur.green(' ✓')} settings.json (hooks merged)`);
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { createProjectCommand } from './commands/install-project.js';
|
|
|
13
13
|
import { createStatusCommand } from './commands/status.js';
|
|
14
14
|
import { createResetCommand } from './commands/reset.js';
|
|
15
15
|
import { createHelpCommand } from './commands/help.js';
|
|
16
|
+
import { createCleanCommand } from './commands/clean.js';
|
|
16
17
|
import { printBanner } from './utils/banner.js';
|
|
17
18
|
|
|
18
19
|
const program = new Command();
|
|
@@ -36,6 +37,7 @@ program.addCommand(createInstallCommand());
|
|
|
36
37
|
program.addCommand(createProjectCommand());
|
|
37
38
|
program.addCommand(createStatusCommand());
|
|
38
39
|
program.addCommand(createResetCommand());
|
|
40
|
+
program.addCommand(createCleanCommand());
|
|
39
41
|
program.addCommand(createHelpCommand());
|
|
40
42
|
|
|
41
43
|
// Default action: show help
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import beadsExtension from "../../extensions/beads";
|
|
4
|
+
import { SubprocessRunner } from "../../extensions/core/lib";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
|
|
7
|
+
vi.mock("../../extensions/core/lib", async () => {
|
|
8
|
+
return {
|
|
9
|
+
SubprocessRunner: {
|
|
10
|
+
run: vi.fn(),
|
|
11
|
+
},
|
|
12
|
+
EventAdapter: {
|
|
13
|
+
isMutatingFileTool: vi.fn((event) => event.toolName === "write"),
|
|
14
|
+
},
|
|
15
|
+
Logger: vi.fn().mockImplementation(function() {
|
|
16
|
+
this.debug = vi.fn();
|
|
17
|
+
this.info = vi.fn();
|
|
18
|
+
this.warn = vi.fn();
|
|
19
|
+
this.error = vi.fn();
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
vi.mock("node:fs", () => ({
|
|
25
|
+
existsSync: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
describe("Beads Extension", () => {
|
|
29
|
+
let harness: ExtensionHarness;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.resetAllMocks();
|
|
33
|
+
harness = new ExtensionHarness();
|
|
34
|
+
(fs.existsSync as any).mockReturnValue(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should block edits when claim check fails", async () => {
|
|
38
|
+
(SubprocessRunner.run as any).mockImplementation(async (cmd: string, args: string[]) => {
|
|
39
|
+
if (args[0] === "kv" && args[1] === "get") return { code: 1, stdout: "", stderr: "" };
|
|
40
|
+
if (args[0] === "list") {
|
|
41
|
+
return { code: 0, stdout: "Total: 5 issues (3 open, 2 in progress)", stderr: "" };
|
|
42
|
+
}
|
|
43
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
beadsExtension(harness.pi);
|
|
47
|
+
|
|
48
|
+
const result = await harness.emit("tool_call", {
|
|
49
|
+
toolName: "write",
|
|
50
|
+
input: { path: "src/main.ts" },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(result).toBeDefined();
|
|
54
|
+
if (result) {
|
|
55
|
+
expect(result.block).toBe(true);
|
|
56
|
+
expect(result.reason).toContain("No active issue claim");
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should allow edits when an issue is claimed", async () => {
|
|
61
|
+
(SubprocessRunner.run as any).mockImplementation(async (cmd: string, args: string[]) => {
|
|
62
|
+
if (args[0] === "kv" && args[1] === "get") return { code: 0, stdout: "issue-123", stderr: "" };
|
|
63
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
beadsExtension(harness.pi);
|
|
67
|
+
|
|
68
|
+
const result = await harness.emit("tool_call", {
|
|
69
|
+
toolName: "write",
|
|
70
|
+
input: { path: "src/main.ts" },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should block git commit when an issue is claimed", async () => {
|
|
77
|
+
(SubprocessRunner.run as any).mockImplementation(async (cmd: string, args: string[]) => {
|
|
78
|
+
if (args[0] === "kv" && args[1] === "get") return { code: 0, stdout: "issue-123", stderr: "" };
|
|
79
|
+
if (args[0] === "list") {
|
|
80
|
+
return { code: 0, stdout: "Total: 1 issues (0 open, 1 in progress)\n◐ issue-123 Title", stderr: "" };
|
|
81
|
+
}
|
|
82
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
beadsExtension(harness.pi);
|
|
86
|
+
|
|
87
|
+
const result = await harness.emit("tool_call", {
|
|
88
|
+
toolName: "bash",
|
|
89
|
+
input: { command: "git commit -m 'feat: something'" },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(result).toBeDefined();
|
|
93
|
+
if (result) {
|
|
94
|
+
expect(result.block).toBe(true);
|
|
95
|
+
expect(result.reason).toContain("Resolve open claim [issue-123]");
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should inject memory reminder on bd close", async () => {
|
|
100
|
+
(SubprocessRunner.run as any).mockResolvedValue({ code: 0, stdout: "", stderr: "" });
|
|
101
|
+
|
|
102
|
+
beadsExtension(harness.pi);
|
|
103
|
+
|
|
104
|
+
const result = await harness.emit("tool_result", {
|
|
105
|
+
toolName: "bash",
|
|
106
|
+
input: { command: "bd close issue-123" },
|
|
107
|
+
content: [{ type: "text", text: "Issue closed successfully." }],
|
|
108
|
+
isError: false,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(result.content).toHaveLength(2);
|
|
112
|
+
expect(result.content[1].text).toContain("Beads Insight");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should auto-claim session on bd update --claim", async () => {
|
|
116
|
+
const kvSetCalls: string[][] = [];
|
|
117
|
+
(SubprocessRunner.run as any).mockImplementation(async (cmd: string, args: string[]) => {
|
|
118
|
+
if (args[0] === "kv" && args[1] === "set") {
|
|
119
|
+
kvSetCalls.push(args);
|
|
120
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
121
|
+
}
|
|
122
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
beadsExtension(harness.pi);
|
|
126
|
+
|
|
127
|
+
const result = await harness.emit("tool_result", {
|
|
128
|
+
toolName: "bash",
|
|
129
|
+
input: { command: "bd update issue-456 --claim" },
|
|
130
|
+
content: [{ type: "text", text: "Updated issue: issue-456" }],
|
|
131
|
+
isError: false,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(kvSetCalls.length).toBe(1);
|
|
135
|
+
expect(kvSetCalls[0][2]).toBe(`claimed:${process.pid}`);
|
|
136
|
+
expect(kvSetCalls[0][3]).toBe("issue-456");
|
|
137
|
+
expect(result.content[1].text).toContain("claimed issue");
|
|
138
|
+
expect(result.content[1].text).toContain("issue-456");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
it("should auto-claim even when bd update --claim returns exit 1 (already in_progress)", async () => {
|
|
143
|
+
const kvSetCalls: string[][] = [];
|
|
144
|
+
(SubprocessRunner.run as any).mockImplementation(async (cmd: string, args: string[]) => {
|
|
145
|
+
if (args[0] === "kv" && args[1] === "set") {
|
|
146
|
+
kvSetCalls.push(args);
|
|
147
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
148
|
+
}
|
|
149
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
beadsExtension(harness.pi);
|
|
153
|
+
|
|
154
|
+
const result = await harness.emit("tool_result", {
|
|
155
|
+
toolName: "bash",
|
|
156
|
+
input: { command: "bd update issue-789 --claim" },
|
|
157
|
+
content: [{ type: "text", text: "already in_progress" }],
|
|
158
|
+
isError: true,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(kvSetCalls.length).toBe(1);
|
|
162
|
+
expect(kvSetCalls[0][2]).toBe(`claimed:${process.pid}`);
|
|
163
|
+
expect(kvSetCalls[0][3]).toBe("issue-789");
|
|
164
|
+
expect(result.content[1].text).toContain("issue-789");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
export interface MockUI {
|
|
4
|
+
notify: any;
|
|
5
|
+
confirm: any;
|
|
6
|
+
select: any;
|
|
7
|
+
setStatus: any;
|
|
8
|
+
theme: {
|
|
9
|
+
fg: any;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MockSessionManager {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
getEntries: any;
|
|
16
|
+
getLeafEntry: any;
|
|
17
|
+
getBranch: any;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MockContext {
|
|
21
|
+
cwd: string;
|
|
22
|
+
hasUI: boolean;
|
|
23
|
+
ui: MockUI;
|
|
24
|
+
sessionManager: MockSessionManager;
|
|
25
|
+
getSystemPrompt: any;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ExtensionHarness {
|
|
29
|
+
public handlers: Record<string, Function[]> = {};
|
|
30
|
+
public commands: Record<string, any> = {};
|
|
31
|
+
public tools: Record<string, any> = {};
|
|
32
|
+
public ctx: MockContext;
|
|
33
|
+
public pi: any;
|
|
34
|
+
|
|
35
|
+
constructor(cwd: string = "/mock/project") {
|
|
36
|
+
this.ctx = {
|
|
37
|
+
cwd,
|
|
38
|
+
hasUI: true,
|
|
39
|
+
ui: {
|
|
40
|
+
notify: vi.fn(),
|
|
41
|
+
confirm: vi.fn().mockResolvedValue(true),
|
|
42
|
+
select: vi.fn().mockResolvedValue(""),
|
|
43
|
+
setStatus: vi.fn(),
|
|
44
|
+
theme: {
|
|
45
|
+
fg: vi.fn((_color: string, text: string) => text),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
sessionManager: {
|
|
49
|
+
sessionId: "mock-session-123",
|
|
50
|
+
getEntries: vi.fn().mockReturnValue([]),
|
|
51
|
+
getLeafEntry: vi.fn().mockReturnValue({ id: "last-entry" }),
|
|
52
|
+
getBranch: vi.fn().mockReturnValue([]),
|
|
53
|
+
},
|
|
54
|
+
getSystemPrompt: vi.fn().mockReturnValue("Default system prompt"),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
this.pi = {
|
|
58
|
+
on: (event: string, handler: Function) => {
|
|
59
|
+
if (!this.handlers[event]) this.handlers[event] = [];
|
|
60
|
+
this.handlers[event].push(handler);
|
|
61
|
+
},
|
|
62
|
+
exec: vi.fn().mockResolvedValue({ code: 0, stdout: "", stderr: "" }),
|
|
63
|
+
registerCommand: (cmd: any) => {
|
|
64
|
+
this.commands[cmd.name] = cmd;
|
|
65
|
+
},
|
|
66
|
+
registerTool: (tool: any) => {
|
|
67
|
+
this.tools[tool.name] = tool;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async emit(event: string, data: any) {
|
|
73
|
+
if (this.handlers[event]) {
|
|
74
|
+
let lastResult: any = undefined;
|
|
75
|
+
for (const handler of this.handlers[event]) {
|
|
76
|
+
console.log("EXECUTING HANDLER"); const res = await handler(data, this.ctx); console.log("HANDLER RESULT", res);
|
|
77
|
+
if (res !== undefined) {
|
|
78
|
+
lastResult = res; console.log("EMIT FOUND RESULT", res);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return lastResult;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import mainGuardExtension from "../../extensions/main-guard";
|
|
4
|
+
import { SubprocessRunner } from "../../extensions/core/lib";
|
|
5
|
+
|
|
6
|
+
vi.mock("../../extensions/core/lib", async () => {
|
|
7
|
+
return {
|
|
8
|
+
SubprocessRunner: {
|
|
9
|
+
run: vi.fn(),
|
|
10
|
+
},
|
|
11
|
+
EventAdapter: {
|
|
12
|
+
isMutatingFileTool: vi.fn((event) => event.toolName === "write" || event.toolName === "edit"),
|
|
13
|
+
extractPathFromToolInput: vi.fn((event) => event.input.path),
|
|
14
|
+
},
|
|
15
|
+
Logger: vi.fn().mockImplementation(function() {
|
|
16
|
+
this.debug = vi.fn();
|
|
17
|
+
this.info = vi.fn();
|
|
18
|
+
this.warn = vi.fn();
|
|
19
|
+
this.error = vi.fn();
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("Main Guard Extension", () => {
|
|
25
|
+
let harness: ExtensionHarness;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.resetAllMocks();
|
|
29
|
+
harness = new ExtensionHarness();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should block edits on main branch", async () => {
|
|
33
|
+
(SubprocessRunner.run as any).mockResolvedValue({ code: 0, stdout: "main" });
|
|
34
|
+
|
|
35
|
+
mainGuardExtension(harness.pi);
|
|
36
|
+
|
|
37
|
+
const result = await harness.emit("tool_call", {
|
|
38
|
+
toolName: "write",
|
|
39
|
+
input: { path: "src/main.ts" },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(result).toEqual({
|
|
43
|
+
block: true,
|
|
44
|
+
reason: expect.stringContaining("On protected branch 'main'"),
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should allow edits on feature branches", async () => {
|
|
49
|
+
(SubprocessRunner.run as any).mockResolvedValue({ code: 0, stdout: "feature/abc" });
|
|
50
|
+
|
|
51
|
+
mainGuardExtension(harness.pi);
|
|
52
|
+
|
|
53
|
+
const result = await harness.emit("tool_call", {
|
|
54
|
+
toolName: "write",
|
|
55
|
+
input: { path: "src/main.ts" },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(result).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should block rm -rf with confirmation", async () => {
|
|
62
|
+
(SubprocessRunner.run as any).mockResolvedValue({ code: 0, stdout: "feature/abc" });
|
|
63
|
+
harness.ctx.ui.confirm = vi.fn().mockResolvedValue(false);
|
|
64
|
+
|
|
65
|
+
mainGuardExtension(harness.pi);
|
|
66
|
+
|
|
67
|
+
const result = await harness.emit("tool_call", {
|
|
68
|
+
toolName: "bash",
|
|
69
|
+
input: { command: "rm -rf /important/stuff" },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(result).toEqual({
|
|
73
|
+
block: true,
|
|
74
|
+
reason: "Blocked by user confirmation",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import qualityGatesExtension from "../../extensions/quality-gates";
|
|
4
|
+
import { SubprocessRunner } from "../../extensions/core/lib";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
|
|
7
|
+
vi.mock("../../extensions/core/lib", async () => {
|
|
8
|
+
return {
|
|
9
|
+
SubprocessRunner: {
|
|
10
|
+
run: vi.fn(),
|
|
11
|
+
},
|
|
12
|
+
EventAdapter: {
|
|
13
|
+
isMutatingFileTool: vi.fn((event) => event.toolName === "write" || event.toolName === "edit"),
|
|
14
|
+
extractPathFromToolInput: vi.fn((event) => event.input.path),
|
|
15
|
+
},
|
|
16
|
+
Logger: vi.fn().mockImplementation(function() {
|
|
17
|
+
this.debug = vi.fn();
|
|
18
|
+
this.info = vi.fn();
|
|
19
|
+
this.warn = vi.fn();
|
|
20
|
+
this.error = vi.fn();
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
vi.mock("node:fs", () => ({
|
|
26
|
+
existsSync: vi.fn(),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
describe("Quality Gates Extension", () => {
|
|
30
|
+
let harness: ExtensionHarness;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.resetAllMocks();
|
|
34
|
+
harness = new ExtensionHarness();
|
|
35
|
+
(fs.existsSync as any).mockReturnValue(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should run quality check for .ts files", async () => {
|
|
39
|
+
(SubprocessRunner.run as any).mockResolvedValue({
|
|
40
|
+
code: 0,
|
|
41
|
+
stdout: "Passed",
|
|
42
|
+
stderr: "ESLint auto-fixed issues",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
qualityGatesExtension(harness.pi);
|
|
46
|
+
|
|
47
|
+
const result = await harness.emit("tool_result", {
|
|
48
|
+
toolName: "write",
|
|
49
|
+
input: { path: "src/main.ts" },
|
|
50
|
+
content: [{ type: "text", text: "Original content" }],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(SubprocessRunner.run).toHaveBeenCalledWith(
|
|
54
|
+
"node",
|
|
55
|
+
expect.arrayContaining([expect.stringContaining("quality-check.cjs")]),
|
|
56
|
+
expect.any(Object)
|
|
57
|
+
);
|
|
58
|
+
expect(result.content[1].text).toContain("ESLint auto-fixed issues");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should fail tool result when quality check returns status 2", async () => {
|
|
62
|
+
(SubprocessRunner.run as any).mockResolvedValue({
|
|
63
|
+
code: 2,
|
|
64
|
+
stdout: "",
|
|
65
|
+
stderr: "Compilation failed: error TS1234",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
qualityGatesExtension(harness.pi);
|
|
69
|
+
|
|
70
|
+
const result = await harness.emit("tool_result", {
|
|
71
|
+
toolName: "write",
|
|
72
|
+
input: { path: "src/main.ts" },
|
|
73
|
+
content: [{ type: "text", text: "Original content" }],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(result.isError).toBe(true);
|
|
77
|
+
expect(result.content[1].text).toContain("Compilation failed");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import xtrmLoaderExtension from "../../extensions/xtrm-loader";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
|
|
6
|
+
vi.mock("node:fs", () => ({
|
|
7
|
+
existsSync: vi.fn(),
|
|
8
|
+
readFileSync: vi.fn(),
|
|
9
|
+
readdirSync: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe("XTRM Loader Extension", () => {
|
|
13
|
+
let harness: ExtensionHarness;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.resetAllMocks();
|
|
17
|
+
harness = new ExtensionHarness();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should load project roadmap and rules", async () => {
|
|
21
|
+
(fs.existsSync as any).mockImplementation((p: string) => {
|
|
22
|
+
if (p.endsWith("ROADMAP.md")) return true;
|
|
23
|
+
if (p.endsWith(".claude/rules")) return true;
|
|
24
|
+
if (p.endsWith(".claude/skills")) return false;
|
|
25
|
+
return false;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
(fs.readFileSync as any).mockImplementation((p: string) => {
|
|
29
|
+
if (p.endsWith("ROADMAP.md")) return "My Roadmap Content";
|
|
30
|
+
if (p.endsWith("rule1.md")) return "Rule 1 Content";
|
|
31
|
+
return "";
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
(fs.readdirSync as any).mockImplementation((p: string) => {
|
|
35
|
+
if (p.endsWith(".claude/rules")) return [{ name: "rule1.md", isFile: () => true, isDirectory: () => false }];
|
|
36
|
+
return [];
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
xtrmLoaderExtension(harness.pi);
|
|
40
|
+
|
|
41
|
+
// Trigger session_start to load data
|
|
42
|
+
await harness.emit("session_start", {});
|
|
43
|
+
|
|
44
|
+
// Trigger before_agent_start to see injection
|
|
45
|
+
const result = await harness.emit("before_agent_start", {
|
|
46
|
+
systemPrompt: "Base prompt"
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(result.systemPrompt).toContain("My Roadmap Content");
|
|
50
|
+
expect(result.systemPrompt).toContain("Rule 1 Content");
|
|
51
|
+
expect(harness.ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("context and skills indexed"), "info");
|
|
52
|
+
});
|
|
53
|
+
});
|