@ilya-lesikov/pi-pi 0.5.0 → 0.6.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/3p/pi-ask-user/index.ts +65 -49
- package/3p/pi-subagents/src/agent-manager.ts +8 -0
- package/3p/pi-subagents/src/agent-runner.ts +112 -19
- package/3p/pi-subagents/src/index.ts +3 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
- package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
- package/extensions/orchestrator/agents/constraints.ts +55 -0
- package/extensions/orchestrator/agents/explore.ts +13 -13
- package/extensions/orchestrator/agents/librarian.ts +12 -9
- package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
- package/extensions/orchestrator/agents/planner.ts +28 -23
- package/extensions/orchestrator/agents/registry.ts +2 -1
- package/extensions/orchestrator/agents/repo-context.ts +11 -0
- package/extensions/orchestrator/agents/task.ts +14 -11
- package/extensions/orchestrator/agents/tool-routing.ts +17 -32
- package/extensions/orchestrator/ast-search.ts +2 -1
- package/extensions/orchestrator/cbm.test.ts +35 -0
- package/extensions/orchestrator/cbm.ts +43 -13
- package/extensions/orchestrator/command-handlers.test.ts +390 -19
- package/extensions/orchestrator/command-handlers.ts +68 -28
- package/extensions/orchestrator/commands.test.ts +255 -2
- package/extensions/orchestrator/commands.ts +108 -10
- package/extensions/orchestrator/config.test.ts +289 -68
- package/extensions/orchestrator/config.ts +630 -121
- package/extensions/orchestrator/context.test.ts +177 -10
- package/extensions/orchestrator/context.ts +115 -14
- package/extensions/orchestrator/custom-footer.ts +2 -1
- package/extensions/orchestrator/doctor.test.ts +559 -0
- package/extensions/orchestrator/doctor.ts +664 -0
- package/extensions/orchestrator/event-handlers.test.ts +84 -22
- package/extensions/orchestrator/event-handlers.ts +1177 -341
- package/extensions/orchestrator/exa.test.ts +46 -0
- package/extensions/orchestrator/exa.ts +16 -10
- package/extensions/orchestrator/flant-infra.test.ts +224 -0
- package/extensions/orchestrator/flant-infra.ts +110 -41
- package/extensions/orchestrator/index.ts +13 -2
- package/extensions/orchestrator/integration.test.ts +2866 -118
- package/extensions/orchestrator/log.test.ts +219 -0
- package/extensions/orchestrator/log.ts +153 -0
- package/extensions/orchestrator/model-registry.test.ts +238 -0
- package/extensions/orchestrator/model-registry.ts +282 -0
- package/extensions/orchestrator/model-version.test.ts +27 -0
- package/extensions/orchestrator/model-version.ts +19 -0
- package/extensions/orchestrator/orchestrator.test.ts +206 -56
- package/extensions/orchestrator/orchestrator.ts +287 -140
- package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
- package/extensions/orchestrator/phases/brainstorm.ts +41 -36
- package/extensions/orchestrator/phases/implementation.ts +7 -11
- package/extensions/orchestrator/phases/machine.test.ts +27 -8
- package/extensions/orchestrator/phases/machine.ts +13 -0
- package/extensions/orchestrator/phases/planning.ts +57 -31
- package/extensions/orchestrator/phases/review-task.ts +3 -7
- package/extensions/orchestrator/phases/review.ts +38 -39
- package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
- package/extensions/orchestrator/phases/verdict.test.ts +139 -0
- package/extensions/orchestrator/phases/verdict.ts +82 -0
- package/extensions/orchestrator/plannotator.test.ts +85 -0
- package/extensions/orchestrator/plannotator.ts +24 -6
- package/extensions/orchestrator/pp-menu.test.ts +134 -0
- package/extensions/orchestrator/pp-menu.ts +2557 -347
- package/extensions/orchestrator/repo-utils.test.ts +151 -0
- package/extensions/orchestrator/repo-utils.ts +67 -0
- package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
- package/extensions/orchestrator/spawn-cleanup.ts +35 -0
- package/extensions/orchestrator/state.test.ts +76 -6
- package/extensions/orchestrator/state.ts +89 -26
- package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
- package/extensions/orchestrator/test-helpers.ts +217 -0
- package/extensions/orchestrator/tracer.test.ts +132 -0
- package/extensions/orchestrator/tracer.ts +127 -0
- package/extensions/orchestrator/transition-controller.test.ts +207 -0
- package/extensions/orchestrator/transition-controller.ts +259 -0
- package/extensions/orchestrator/usage-tracker.test.ts +435 -0
- package/extensions/orchestrator/usage-tracker.ts +23 -2
- package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
- package/package.json +2 -1
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({
|
|
7
|
+
readRawConfig: vi.fn(),
|
|
8
|
+
mergeConfigLayers: vi.fn(),
|
|
9
|
+
resolvePreset: vi.fn(),
|
|
10
|
+
resolveModel: vi.fn(),
|
|
11
|
+
getAllAliases: vi.fn(),
|
|
12
|
+
loadFlantSettings: vi.fn(),
|
|
13
|
+
execFileSync: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("child_process", () => ({
|
|
17
|
+
execFileSync: mocks.execFileSync,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("./config.js", () => ({
|
|
21
|
+
GLOBAL_CONFIG_PATH: "/mock/global-config.json",
|
|
22
|
+
PRESET_GROUPS: ["planners", "codeReviewers", "planReviewers", "brainstormReviewers"],
|
|
23
|
+
readRawConfig: mocks.readRawConfig,
|
|
24
|
+
mergeConfigLayers: mocks.mergeConfigLayers,
|
|
25
|
+
resolvePreset: mocks.resolvePreset,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock("./model-registry.js", () => ({
|
|
29
|
+
resolveModel: mocks.resolveModel,
|
|
30
|
+
getAllAliases: mocks.getAllAliases,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock("./flant-infra.js", () => ({
|
|
34
|
+
loadFlantSettings: mocks.loadFlantSettings,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
import { runDoctor } from "./doctor.js";
|
|
38
|
+
|
|
39
|
+
const tempDirs: string[] = [];
|
|
40
|
+
|
|
41
|
+
function makeTempDir(prefix: string): string {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
43
|
+
tempDirs.push(dir);
|
|
44
|
+
return dir;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createConfig() {
|
|
48
|
+
return {
|
|
49
|
+
general: {
|
|
50
|
+
autoCommit: true,
|
|
51
|
+
loadExtraRepoConfigs: true,
|
|
52
|
+
logLevel: "info",
|
|
53
|
+
},
|
|
54
|
+
agents: {
|
|
55
|
+
orchestrators: {
|
|
56
|
+
implement: { model: "anthropic/claude-opus-latest", thinking: "high" },
|
|
57
|
+
plan: { model: "anthropic/claude-opus-latest", thinking: "high" },
|
|
58
|
+
debug: { model: "openai/gpt-latest", thinking: "high" },
|
|
59
|
+
brainstorm: { model: "anthropic/claude-opus-latest", thinking: "high" },
|
|
60
|
+
review: { model: "anthropic/claude-opus-latest", thinking: "high" },
|
|
61
|
+
},
|
|
62
|
+
subagents: {
|
|
63
|
+
simple: {
|
|
64
|
+
explore: { model: "google/gemini-flash-latest", thinking: "low" },
|
|
65
|
+
librarian: { model: "google/gemini-flash-latest", thinking: "medium" },
|
|
66
|
+
task: { model: "openai/gpt-latest", thinking: "medium" },
|
|
67
|
+
},
|
|
68
|
+
presetGroups: {
|
|
69
|
+
planners: {
|
|
70
|
+
default: "regular",
|
|
71
|
+
presets: {
|
|
72
|
+
regular: {
|
|
73
|
+
enabled: true,
|
|
74
|
+
agents: {
|
|
75
|
+
opus: { enabled: true, model: "anthropic/claude-opus-latest", thinking: "high" },
|
|
76
|
+
gpt: { enabled: true, model: "openai/gpt-latest", thinking: "high" },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
codeReviewers: {
|
|
82
|
+
default: "regular",
|
|
83
|
+
presets: {
|
|
84
|
+
regular: {
|
|
85
|
+
enabled: true,
|
|
86
|
+
agents: {
|
|
87
|
+
gemini: { enabled: true, model: "google/gemini-pro-latest", thinking: "high" },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
planReviewers: {
|
|
93
|
+
default: "regular",
|
|
94
|
+
presets: {
|
|
95
|
+
regular: {
|
|
96
|
+
enabled: true,
|
|
97
|
+
agents: {
|
|
98
|
+
opus: { enabled: true, model: "anthropic/claude-opus-latest", thinking: "high" },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
brainstormReviewers: {
|
|
104
|
+
default: "regular",
|
|
105
|
+
presets: {
|
|
106
|
+
regular: {
|
|
107
|
+
enabled: true,
|
|
108
|
+
agents: {
|
|
109
|
+
gpt: { enabled: true, model: "openai/gpt-latest", thinking: "high" },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
commands: {
|
|
118
|
+
afterEdit: {
|
|
119
|
+
lint: { run: "node ./scripts/lint.js", globs: ["**/*.ts"] },
|
|
120
|
+
},
|
|
121
|
+
afterImplement: {
|
|
122
|
+
test: { run: "npm test" },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
performance: {
|
|
126
|
+
commands: {
|
|
127
|
+
afterEdit: 30000,
|
|
128
|
+
afterImplement: 300000,
|
|
129
|
+
},
|
|
130
|
+
internals: {
|
|
131
|
+
subagentStale: 300000,
|
|
132
|
+
taskLockStale: 60000,
|
|
133
|
+
taskLockRefresh: 30000,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function createCtx() {
|
|
140
|
+
return {
|
|
141
|
+
ui: {
|
|
142
|
+
notify: vi.fn(),
|
|
143
|
+
},
|
|
144
|
+
modelRegistry: {
|
|
145
|
+
getAvailable: vi.fn(() => [
|
|
146
|
+
{ provider: "anthropic", id: "claude-opus-4-6" },
|
|
147
|
+
{ provider: "openai", id: "gpt-5.4" },
|
|
148
|
+
{ provider: "google", id: "gemini-3.1-flash" },
|
|
149
|
+
{ provider: "google", id: "gemini-3.1-pro" },
|
|
150
|
+
]),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
beforeEach(() => {
|
|
156
|
+
const aliasMap: Record<string, string> = {
|
|
157
|
+
"anthropic/claude-opus-latest": "anthropic/claude-opus-4-6",
|
|
158
|
+
"openai/gpt-latest": "openai/gpt-5.4",
|
|
159
|
+
"google/gemini-flash-latest": "google/gemini-3.1-flash",
|
|
160
|
+
"google/gemini-pro-latest": "google/gemini-3.1-pro",
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
mocks.resolveModel.mockImplementation((value: string) => aliasMap[value] ?? value);
|
|
164
|
+
mocks.getAllAliases.mockReturnValue({ ...aliasMap });
|
|
165
|
+
mocks.mergeConfigLayers.mockReturnValue(createConfig());
|
|
166
|
+
mocks.readRawConfig.mockImplementation(() => ({}));
|
|
167
|
+
mocks.resolvePreset.mockImplementation((config: any, group: string, presetName?: string) => {
|
|
168
|
+
const groupConfig = config.agents.subagents.presetGroups[group];
|
|
169
|
+
const chosen = presetName ?? groupConfig.default;
|
|
170
|
+
return groupConfig.presets[chosen]?.agents ?? {};
|
|
171
|
+
});
|
|
172
|
+
mocks.loadFlantSettings.mockReturnValue({
|
|
173
|
+
enabled: true,
|
|
174
|
+
autoUpdate: true,
|
|
175
|
+
cacheTTLDays: 7,
|
|
176
|
+
lastUpdated: null,
|
|
177
|
+
cachedFlantModels: null,
|
|
178
|
+
cachedOpenRouterData: null,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
mocks.execFileSync.mockImplementation((command: string, args: string[]) => {
|
|
182
|
+
if (command !== "which") throw new Error(`Unexpected command: ${command}`);
|
|
183
|
+
const bin = args[0];
|
|
184
|
+
if (bin === "git") return "/usr/bin/git\n";
|
|
185
|
+
if (bin === "gh") return "/usr/bin/gh\n";
|
|
186
|
+
if (bin === "codebase-memory-mcp") return "/usr/bin/codebase-memory-mcp\n";
|
|
187
|
+
if (bin === "sg") return "/usr/bin/sg\n";
|
|
188
|
+
if (bin === "node") return "/usr/bin/node\n";
|
|
189
|
+
if (bin === "npm") return "/usr/bin/npm\n";
|
|
190
|
+
throw new Error(`${bin} not found`);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const fetchMock = vi.fn(async (url: string) => {
|
|
194
|
+
if (url === "https://llm-api.flant.ru/v1/models") {
|
|
195
|
+
return {
|
|
196
|
+
ok: true,
|
|
197
|
+
status: 200,
|
|
198
|
+
json: async () => ({ data: [{ id: "claude-opus-4-6" }, { id: "gpt-5-4" }] }),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (url === "https://openrouter.ai/api/v1/models") {
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
status: 200,
|
|
205
|
+
json: async () => ({ data: [{ id: "a" }, { id: "b" }] }),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (url === "https://mcp.exa.ai/mcp") {
|
|
209
|
+
return {
|
|
210
|
+
ok: true,
|
|
211
|
+
status: 200,
|
|
212
|
+
json: async () => ({}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
throw new Error(`Unexpected fetch URL: ${url}`);
|
|
216
|
+
});
|
|
217
|
+
(globalThis as any).fetch = fetchMock;
|
|
218
|
+
|
|
219
|
+
(globalThis as any)[Symbol.for("pi-pi:cbm-daemon")] = { proc: {} };
|
|
220
|
+
(globalThis as any)[Symbol.for("pi-lsp:api")] = { restart: vi.fn(async () => undefined) };
|
|
221
|
+
|
|
222
|
+
process.env.FLANT_API_KEY = "flant-key";
|
|
223
|
+
const agentDir = makeTempDir("pi-pi-doctor-agent-");
|
|
224
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
225
|
+
mkdirSync(join(agentDir, "extensions", "pp", "cache"), { recursive: true });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
afterEach(() => {
|
|
229
|
+
vi.clearAllMocks();
|
|
230
|
+
delete process.env.FLANT_API_KEY;
|
|
231
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
232
|
+
delete (globalThis as any).fetch;
|
|
233
|
+
delete (globalThis as any)[Symbol.for("pi-pi:cbm-daemon")];
|
|
234
|
+
delete (globalThis as any)[Symbol.for("pi-lsp:api")];
|
|
235
|
+
for (const dir of tempDirs.splice(0)) {
|
|
236
|
+
rmSync(dir, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("runDoctor", () => {
|
|
241
|
+
it("produces a full multi-category report", async () => {
|
|
242
|
+
const cwd = makeTempDir("pi-pi-doctor-cwd-");
|
|
243
|
+
const ctx = createCtx();
|
|
244
|
+
const orchestrator = {
|
|
245
|
+
cwd,
|
|
246
|
+
config: createConfig(),
|
|
247
|
+
active: null,
|
|
248
|
+
} as any;
|
|
249
|
+
|
|
250
|
+
await runDoctor(orchestrator, ctx);
|
|
251
|
+
|
|
252
|
+
expect(ctx.ui.notify).toHaveBeenCalledTimes(1);
|
|
253
|
+
const [report, kind] = ctx.ui.notify.mock.calls[0] as [string, string];
|
|
254
|
+
expect(kind).toBe("info");
|
|
255
|
+
expect(report).toContain("Doctor Results");
|
|
256
|
+
expect(report).toContain("\nConfig\n");
|
|
257
|
+
expect(report).toContain("\nModels\n");
|
|
258
|
+
expect(report).toContain("\nPresets\n");
|
|
259
|
+
expect(report).toContain("\nTools\n");
|
|
260
|
+
expect(report).toContain("\nCommands\n");
|
|
261
|
+
expect(report).toContain("\nFlant\n");
|
|
262
|
+
expect(report).toContain("\nLSP\n");
|
|
263
|
+
expect(report).toContain("\nConnectivity\n");
|
|
264
|
+
expect(report).toContain("\nRepos\n");
|
|
265
|
+
expect(report).toMatch(/\n [✓⚠✗] /);
|
|
266
|
+
expect(report).toMatch(/Summary: \d+ passed, \d+ warnings, \d+ failures/);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("continues and reports failures when individual checks throw", async () => {
|
|
270
|
+
const cwd = makeTempDir("pi-pi-doctor-failure-");
|
|
271
|
+
const ctx = createCtx();
|
|
272
|
+
const orchestrator = {
|
|
273
|
+
cwd,
|
|
274
|
+
config: createConfig(),
|
|
275
|
+
active: null,
|
|
276
|
+
} as any;
|
|
277
|
+
|
|
278
|
+
mocks.readRawConfig.mockImplementation(() => {
|
|
279
|
+
throw new Error("parse error");
|
|
280
|
+
});
|
|
281
|
+
mocks.mergeConfigLayers.mockImplementation(() => {
|
|
282
|
+
throw new Error("merge exploded");
|
|
283
|
+
});
|
|
284
|
+
(globalThis as any).fetch = vi.fn(async () => {
|
|
285
|
+
throw new Error("network down");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await expect(runDoctor(orchestrator, ctx)).resolves.toBeUndefined();
|
|
289
|
+
|
|
290
|
+
expect(ctx.ui.notify).toHaveBeenCalledTimes(1);
|
|
291
|
+
const [report] = ctx.ui.notify.mock.calls[0] as [string, string];
|
|
292
|
+
expect(report).toContain("Config files parseable");
|
|
293
|
+
expect(report).toContain("Config layer merge failed: merge exploded");
|
|
294
|
+
expect(report).toContain("Connectivity checks failed: network down");
|
|
295
|
+
expect(report).toContain("Summary:");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("probes Flant, OpenRouter, and Exa over HTTP", async () => {
|
|
299
|
+
const cwd = makeTempDir("pi-pi-doctor-network-");
|
|
300
|
+
const ctx = createCtx();
|
|
301
|
+
const orchestrator = {
|
|
302
|
+
cwd,
|
|
303
|
+
config: createConfig(),
|
|
304
|
+
active: null,
|
|
305
|
+
} as any;
|
|
306
|
+
|
|
307
|
+
const fetchMock = vi.fn(async (url: string) => {
|
|
308
|
+
if (url === "https://llm-api.flant.ru/v1/models") {
|
|
309
|
+
return { ok: true, status: 200, json: async () => ({ data: [{ id: "gpt-5-4" }] }) };
|
|
310
|
+
}
|
|
311
|
+
if (url === "https://openrouter.ai/api/v1/models") {
|
|
312
|
+
return { ok: true, status: 200, json: async () => ({ data: [{ id: "openrouter/a" }] }) };
|
|
313
|
+
}
|
|
314
|
+
if (url === "https://mcp.exa.ai/mcp") {
|
|
315
|
+
return { ok: true, status: 200, json: async () => ({}) };
|
|
316
|
+
}
|
|
317
|
+
throw new Error("unexpected");
|
|
318
|
+
});
|
|
319
|
+
(globalThis as any).fetch = fetchMock;
|
|
320
|
+
|
|
321
|
+
await runDoctor(orchestrator, ctx);
|
|
322
|
+
|
|
323
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
324
|
+
"https://llm-api.flant.ru/v1/models",
|
|
325
|
+
expect.objectContaining({ method: "GET" }),
|
|
326
|
+
);
|
|
327
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
328
|
+
"https://openrouter.ai/api/v1/models",
|
|
329
|
+
expect.objectContaining({ method: "GET" }),
|
|
330
|
+
);
|
|
331
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
332
|
+
"https://mcp.exa.ai/mcp",
|
|
333
|
+
expect.objectContaining({ method: "POST" }),
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("does not warn about empty overrides when config files are missing", async () => {
|
|
338
|
+
const cwd = makeTempDir("pi-pi-doctor-missing-config-");
|
|
339
|
+
const ctx = createCtx();
|
|
340
|
+
const orchestrator = {
|
|
341
|
+
cwd,
|
|
342
|
+
config: createConfig(),
|
|
343
|
+
active: null,
|
|
344
|
+
} as any;
|
|
345
|
+
|
|
346
|
+
await runDoctor(orchestrator, ctx);
|
|
347
|
+
|
|
348
|
+
const [report] = ctx.ui.notify.mock.calls[0] as [string, string];
|
|
349
|
+
expect(report).toContain("No empty overrides");
|
|
350
|
+
expect(report).not.toContain("Empty override objects: global");
|
|
351
|
+
expect(report).not.toContain("Empty override objects: project");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("reports git and gh binary availability", async () => {
|
|
355
|
+
const cwd = makeTempDir("pi-pi-doctor-tools-");
|
|
356
|
+
const ctx = createCtx();
|
|
357
|
+
const orchestrator = {
|
|
358
|
+
cwd,
|
|
359
|
+
config: createConfig(),
|
|
360
|
+
active: null,
|
|
361
|
+
} as any;
|
|
362
|
+
|
|
363
|
+
mocks.execFileSync.mockImplementation((command: string, args: string[]) => {
|
|
364
|
+
if (command !== "which") throw new Error(`Unexpected command: ${command}`);
|
|
365
|
+
const bin = args[0];
|
|
366
|
+
if (bin === "git") return "/usr/local/bin/git\n";
|
|
367
|
+
if (bin === "gh") return "/usr/local/bin/gh\n";
|
|
368
|
+
if (bin === "codebase-memory-mcp") return "/usr/bin/codebase-memory-mcp\n";
|
|
369
|
+
if (bin === "sg") return "/usr/bin/sg\n";
|
|
370
|
+
if (bin === "node") return "/usr/bin/node\n";
|
|
371
|
+
if (bin === "npm") return "/usr/bin/npm\n";
|
|
372
|
+
throw new Error(`${bin} not found`);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await runDoctor(orchestrator, ctx);
|
|
376
|
+
|
|
377
|
+
const [report] = ctx.ui.notify.mock.calls[0] as [string, string];
|
|
378
|
+
expect(report).toContain("git: /usr/local/bin/git");
|
|
379
|
+
expect(report).toContain("gh: /usr/local/bin/gh");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("checks current working directory as repo when no task is active", async () => {
|
|
383
|
+
const cwd = makeTempDir("pi-pi-doctor-cwd-repo-");
|
|
384
|
+
mkdirSync(join(cwd, ".git"), { recursive: true });
|
|
385
|
+
const ctx = createCtx();
|
|
386
|
+
const orchestrator = {
|
|
387
|
+
cwd,
|
|
388
|
+
config: createConfig(),
|
|
389
|
+
active: null,
|
|
390
|
+
} as any;
|
|
391
|
+
|
|
392
|
+
mocks.execFileSync.mockImplementation((command: string, args: string[], options?: { cwd?: string }) => {
|
|
393
|
+
if (command === "which") {
|
|
394
|
+
const bin = args[0];
|
|
395
|
+
if (bin === "git") return "/usr/bin/git\n";
|
|
396
|
+
if (bin === "gh") return "/usr/bin/gh\n";
|
|
397
|
+
if (bin === "codebase-memory-mcp") return "/usr/bin/codebase-memory-mcp\n";
|
|
398
|
+
if (bin === "sg") return "/usr/bin/sg\n";
|
|
399
|
+
if (bin === "node") return "/usr/bin/node\n";
|
|
400
|
+
if (bin === "npm") return "/usr/bin/npm\n";
|
|
401
|
+
throw new Error(`${bin} not found`);
|
|
402
|
+
}
|
|
403
|
+
if (command === "git" && args[0] === "status" && args[1] === "--porcelain" && args[2] === "--branch") {
|
|
404
|
+
if (options?.cwd !== cwd) throw new Error("unexpected cwd");
|
|
405
|
+
return "## main\n";
|
|
406
|
+
}
|
|
407
|
+
throw new Error(`Unexpected command: ${command} ${args.join(" ")}`);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
await runDoctor(orchestrator, ctx);
|
|
411
|
+
|
|
412
|
+
const [report] = ctx.ui.notify.mock.calls[0] as [string, string];
|
|
413
|
+
expect(report).toContain(`${cwd}: path exists`);
|
|
414
|
+
expect(report).toContain(`${cwd}: git repository detected`);
|
|
415
|
+
expect(report).toContain(`${cwd}: git status clean (main)`);
|
|
416
|
+
expect(report).not.toContain("repo checks skipped");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("skips disabled commands and parses env-prefixed/path commands", async () => {
|
|
420
|
+
const cwd = makeTempDir("pi-pi-doctor-disabled-commands-");
|
|
421
|
+
mkdirSync(join(cwd, "scripts"), { recursive: true });
|
|
422
|
+
mkdirSync(join(cwd, "bin"), { recursive: true });
|
|
423
|
+
const config = createConfig();
|
|
424
|
+
config.commands.afterEdit = {
|
|
425
|
+
disabled: { run: "npm run lint", enabled: false },
|
|
426
|
+
envprefixed: { run: "FOO=1 BAR=2 node ./scripts/lint.js", enabled: true },
|
|
427
|
+
pathcmd: { run: "./bin/tool --check", enabled: true },
|
|
428
|
+
} as any;
|
|
429
|
+
config.commands.afterImplement = {
|
|
430
|
+
disabledImpl: { run: "npm test", enabled: false },
|
|
431
|
+
} as any;
|
|
432
|
+
writeFileSync(join(cwd, "bin", "tool"), "#!/bin/sh\n", "utf-8");
|
|
433
|
+
|
|
434
|
+
const ctx = createCtx();
|
|
435
|
+
const orchestrator = {
|
|
436
|
+
cwd,
|
|
437
|
+
config,
|
|
438
|
+
active: null,
|
|
439
|
+
} as any;
|
|
440
|
+
|
|
441
|
+
await runDoctor(orchestrator, ctx);
|
|
442
|
+
|
|
443
|
+
const [report] = ctx.ui.notify.mock.calls[0] as [string, string];
|
|
444
|
+
expect(report).toContain("afterEdit.disabled: skipped (disabled)");
|
|
445
|
+
expect(report).toContain("afterImplement.disabledImpl: skipped (disabled)");
|
|
446
|
+
expect(report).toContain("afterEdit.envprefixed: node found");
|
|
447
|
+
expect(report).toContain(`afterEdit.pathcmd: executable path exists at ${join(cwd, "./bin/tool")}`);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("reports Exa timeout/abort errors", async () => {
|
|
451
|
+
const cwd = makeTempDir("pi-pi-doctor-timeout-");
|
|
452
|
+
const ctx = createCtx();
|
|
453
|
+
const orchestrator = {
|
|
454
|
+
cwd,
|
|
455
|
+
config: createConfig(),
|
|
456
|
+
active: null,
|
|
457
|
+
} as any;
|
|
458
|
+
|
|
459
|
+
const fetchMock = vi.fn(async (url: string, options?: RequestInit) => {
|
|
460
|
+
if (url === "https://llm-api.flant.ru/v1/models") {
|
|
461
|
+
return { ok: true, status: 200, json: async () => ({ data: [] }) };
|
|
462
|
+
}
|
|
463
|
+
if (url === "https://openrouter.ai/api/v1/models") {
|
|
464
|
+
return { ok: true, status: 200, json: async () => ({ data: [] }) };
|
|
465
|
+
}
|
|
466
|
+
if (url === "https://mcp.exa.ai/mcp") {
|
|
467
|
+
options?.signal?.dispatchEvent?.(new Event("abort"));
|
|
468
|
+
throw new Error("aborted");
|
|
469
|
+
}
|
|
470
|
+
throw new Error("unexpected");
|
|
471
|
+
});
|
|
472
|
+
(globalThis as any).fetch = fetchMock;
|
|
473
|
+
|
|
474
|
+
await runDoctor(orchestrator, ctx);
|
|
475
|
+
|
|
476
|
+
const [report] = ctx.ui.notify.mock.calls[0] as [string, string];
|
|
477
|
+
expect(report).toContain("Connectivity checks failed: aborted");
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("reports LSP API presence/missing without invoking LSP status", async () => {
|
|
481
|
+
const cwd = makeTempDir("pi-pi-doctor-lsp-");
|
|
482
|
+
const ctx = createCtx();
|
|
483
|
+
const orchestrator = {
|
|
484
|
+
cwd,
|
|
485
|
+
config: createConfig(),
|
|
486
|
+
active: null,
|
|
487
|
+
} as any;
|
|
488
|
+
|
|
489
|
+
const status = vi.fn(async () => undefined);
|
|
490
|
+
(globalThis as any)[Symbol.for("pi-lsp:api")] = { status };
|
|
491
|
+
|
|
492
|
+
await runDoctor(orchestrator, ctx);
|
|
493
|
+
|
|
494
|
+
expect(status).not.toHaveBeenCalled();
|
|
495
|
+
const [withApiReport] = ctx.ui.notify.mock.calls[0] as [string, string];
|
|
496
|
+
expect(withApiReport).toContain("LSP API: available");
|
|
497
|
+
expect(withApiReport).not.toContain("not programmatically exposed");
|
|
498
|
+
|
|
499
|
+
ctx.ui.notify.mockClear();
|
|
500
|
+
delete (globalThis as any)[Symbol.for("pi-lsp:api")];
|
|
501
|
+
|
|
502
|
+
await runDoctor(orchestrator, ctx);
|
|
503
|
+
const [withoutApiReport] = ctx.ui.notify.mock.calls[0] as [string, string];
|
|
504
|
+
expect(withoutApiReport).toContain("LSP API: not available");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("validates repos when active task has registered repos", async () => {
|
|
508
|
+
const root = makeTempDir("pi-pi-doctor-repos-");
|
|
509
|
+
const validRepo = join(root, "repo-valid");
|
|
510
|
+
const invalidRepo = join(root, "repo-invalid");
|
|
511
|
+
mkdirSync(join(validRepo, ".git"), { recursive: true });
|
|
512
|
+
mkdirSync(invalidRepo, { recursive: true });
|
|
513
|
+
|
|
514
|
+
mocks.execFileSync.mockImplementation((command: string, args: string[]) => {
|
|
515
|
+
if (command === "which") {
|
|
516
|
+
const bin = args[0];
|
|
517
|
+
if (bin === "git") return "/usr/bin/git\n";
|
|
518
|
+
if (bin === "gh") return "/usr/bin/gh\n";
|
|
519
|
+
if (bin === "codebase-memory-mcp") return "/usr/bin/codebase-memory-mcp\n";
|
|
520
|
+
if (bin === "sg") return "/usr/bin/sg\n";
|
|
521
|
+
if (bin === "node") return "/usr/bin/node\n";
|
|
522
|
+
if (bin === "npm") return "/usr/bin/npm\n";
|
|
523
|
+
throw new Error(`${bin} not found`);
|
|
524
|
+
}
|
|
525
|
+
if (command === "git" && args[0] === "show-ref" && args[1] === "--verify") {
|
|
526
|
+
const ref = args[2];
|
|
527
|
+
if (ref === "refs/remotes/origin/main") return "hash\n";
|
|
528
|
+
throw new Error("missing ref");
|
|
529
|
+
}
|
|
530
|
+
if (command === "git" && args[0] === "rev-parse") {
|
|
531
|
+
throw new Error("not git");
|
|
532
|
+
}
|
|
533
|
+
throw new Error(`Unexpected command: ${command} ${args.join(" ")}`);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const ctx = createCtx();
|
|
537
|
+
const orchestrator = {
|
|
538
|
+
cwd: root,
|
|
539
|
+
config: createConfig(),
|
|
540
|
+
active: {
|
|
541
|
+
state: {
|
|
542
|
+
repos: [
|
|
543
|
+
{ path: validRepo, baseBranch: "origin/main", isRoot: true },
|
|
544
|
+
{ path: invalidRepo, baseBranch: "origin/dev", isRoot: false },
|
|
545
|
+
{ path: join(root, "missing"), baseBranch: "origin/main", isRoot: false },
|
|
546
|
+
],
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
} as any;
|
|
550
|
+
|
|
551
|
+
await runDoctor(orchestrator, ctx);
|
|
552
|
+
|
|
553
|
+
const [report] = ctx.ui.notify.mock.calls[0] as [string, string];
|
|
554
|
+
expect(report).toContain(`${validRepo}: path exists`);
|
|
555
|
+
expect(report).toContain(`${validRepo}: base branch "origin/main" is valid`);
|
|
556
|
+
expect(report).toContain(`${invalidRepo}: not a git repository`);
|
|
557
|
+
expect(report).toContain(`${join(root, "missing")}: path does not exist`);
|
|
558
|
+
});
|
|
559
|
+
});
|