@wwjd/pi-graphify 0.3.0 → 0.4.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.
@@ -0,0 +1,443 @@
1
+ /**
2
+ * Tests for Graphify slash command executors, parsing, and registry.
3
+ */
4
+
5
+ import assert from "node:assert";
6
+ import { resolve } from "node:path";
7
+ import { describe, it } from "node:test";
8
+ import { allCapabilitiesEnabled, createMockBackend } from "../backends/mock.js";
9
+ import { GraphifyCoordinator } from "../coordinator.js";
10
+ import { GraphifyError } from "../errors.js";
11
+ import { affectedCommand } from "./affected.js";
12
+ import { buildCommand } from "./build.js";
13
+ import { explainCommand } from "./explain.js";
14
+ import { parseBooleanFlag, splitArgs } from "./parse.js";
15
+ import { pathCommand } from "./path.js";
16
+ import { queryCommand } from "./query.js";
17
+ import { getAvailableCommands, graphifyCommands } from "./registry.js";
18
+ import { statusCommand } from "./status.js";
19
+ import { createTestCoordinator, getCoordinator } from "./test-helpers.js";
20
+ import type { CommandContext } from "./types.js";
21
+ import { versionCommand } from "./version.js";
22
+
23
+ const TRUNCATION_TEST_OUTPUT_LENGTH = 2000;
24
+ const TRUNCATION_TEST_ASSERTION_LIMIT = 1100;
25
+
26
+ interface Notification {
27
+ message: string;
28
+ type?: "info" | "warning" | "error";
29
+ }
30
+
31
+ interface MockContextResult {
32
+ ctx: CommandContext;
33
+ notifications: Notification[];
34
+ }
35
+
36
+ function createMockContext(overrides: Partial<CommandContext> = {}): MockContextResult {
37
+ const notifications: Notification[] = [];
38
+
39
+ const ctx: CommandContext = {
40
+ cwd: process.cwd(),
41
+ hasUI: true,
42
+ mode: "tui",
43
+ isProjectTrusted: () => true,
44
+ ui: {
45
+ notify: (message, type) => notifications.push({ message, type }),
46
+ input: async () => undefined,
47
+ select: async () => undefined,
48
+ custom: async () => null as never,
49
+ },
50
+ ...overrides,
51
+ };
52
+
53
+ return { ctx, notifications };
54
+ }
55
+
56
+ const expectedCommands = [
57
+ "graphify-build",
58
+ "graphify-query",
59
+ "graphify-path",
60
+ "graphify-explain",
61
+ "graphify-affected",
62
+ "graphify-status",
63
+ "graphify-version",
64
+ ];
65
+
66
+ describe("graphifyCommands registry", () => {
67
+ it("contains all expected commands", () => {
68
+ const names = graphifyCommands.map((c) => c.name);
69
+ for (const name of expectedCommands) {
70
+ assert.ok(names.includes(name), `expected command ${name}`);
71
+ }
72
+ });
73
+
74
+ it("filters commands by capability", () => {
75
+ const backend = createMockBackend({
76
+ capabilities: { ...allCapabilitiesEnabled(), build: false, query: false },
77
+ });
78
+ const coordinator = createTestCoordinator(backend);
79
+ const available = getAvailableCommands(coordinator, graphifyCommands).map((c) => c.name);
80
+
81
+ assert.ok(!available.includes("graphify-build"));
82
+ assert.ok(!available.includes("graphify-query"));
83
+ assert.ok(available.includes("graphify-path"));
84
+ });
85
+
86
+ it("returns no commands when coordinator is null", () => {
87
+ const available = getAvailableCommands(null, graphifyCommands);
88
+ assert.equal(available.length, 0);
89
+ });
90
+ });
91
+
92
+ describe("splitArgs", () => {
93
+ it("splits on whitespace", () => {
94
+ assert.deepEqual(splitArgs("a b c"), ["a", "b", "c"]);
95
+ });
96
+
97
+ it("preserves quoted strings", () => {
98
+ assert.deepEqual(splitArgs('a "b c" d'), ["a", "b c", "d"]);
99
+ assert.deepEqual(splitArgs("a 'b c' d"), ["a", "b c", "d"]);
100
+ });
101
+
102
+ it("returns an empty array for empty input", () => {
103
+ assert.deepEqual(splitArgs(""), []);
104
+ });
105
+ });
106
+
107
+ describe("parseBooleanFlag", () => {
108
+ it("detects a bare flag", () => {
109
+ const result = parseBooleanFlag(["--flag"], "flag");
110
+ assert.equal(result.value, true);
111
+ assert.deepEqual(result.rest, []);
112
+ });
113
+
114
+ it("detects --flag=true and --flag=false", () => {
115
+ const result = parseBooleanFlag(["--flag=true"], "flag", true);
116
+ assert.equal(result.value, true);
117
+ const result2 = parseBooleanFlag(["--flag=false"], "flag", true);
118
+ assert.equal(result2.value, false);
119
+ });
120
+
121
+ it("leaves unrelated tokens in rest", () => {
122
+ const result = parseBooleanFlag(["--code-only", "src/index.ts"], "code-only");
123
+ assert.equal(result.value, true);
124
+ assert.deepEqual(result.rest, ["src/index.ts"]);
125
+ });
126
+
127
+ it("throws on invalid flag value", () => {
128
+ assert.throws(() => parseBooleanFlag(["--flag=maybe"], "flag", true));
129
+ });
130
+ });
131
+
132
+ describe("executeStatus", () => {
133
+ it("notifies when graph is ready", async () => {
134
+ const { ctx, notifications } = createMockContext();
135
+ const coordinator = createTestCoordinator();
136
+ await statusCommand.execute(ctx, getCoordinator(coordinator), "");
137
+
138
+ assert.equal(notifications.length, 1);
139
+ assert.equal(notifications[0].type, "info");
140
+ assert.ok(notifications[0].message.includes("Graphify graph ready"));
141
+ });
142
+
143
+ it("warns when graph is missing", async () => {
144
+ const cwd = "/tmp/no-graph-here";
145
+ const { ctx, notifications } = createMockContext({ cwd });
146
+ const customCoordinator = new GraphifyCoordinator({
147
+ cwd,
148
+ backend: createMockBackend(),
149
+ });
150
+ await statusCommand.execute(ctx, getCoordinator(customCoordinator), "");
151
+
152
+ assert.equal(notifications[0].type, "warning");
153
+ assert.ok(notifications[0].message.includes("No Graphify graph found"));
154
+ });
155
+
156
+ it("warns when coordinator is uninitialized", async () => {
157
+ const { ctx, notifications } = createMockContext();
158
+ await statusCommand.execute(ctx, getCoordinator(null), "");
159
+
160
+ assert.equal(notifications[0].type, "warning");
161
+ assert.ok(notifications[0].message.includes("not initialized"));
162
+ });
163
+
164
+ it("returns silently when hasUI is false", async () => {
165
+ const { ctx, notifications } = createMockContext({ hasUI: false });
166
+ const coordinator = createTestCoordinator();
167
+ await statusCommand.execute(ctx, getCoordinator(coordinator), "");
168
+
169
+ assert.equal(notifications.length, 0);
170
+ });
171
+ });
172
+
173
+ describe("executeVersion", () => {
174
+ it("shows the installed version", async () => {
175
+ const { ctx, notifications } = createMockContext();
176
+ const coordinator = createTestCoordinator();
177
+ await versionCommand.execute(ctx, getCoordinator(coordinator), "");
178
+
179
+ assert.equal(notifications[0].message, "2.100.0");
180
+ });
181
+
182
+ it("warns when version is unsupported", async () => {
183
+ const { ctx, notifications } = createMockContext();
184
+ const backend = createMockBackend({
185
+ capabilities: { ...allCapabilitiesEnabled(), version: false },
186
+ });
187
+ const coordinator = createTestCoordinator(backend);
188
+ await versionCommand.execute(ctx, getCoordinator(coordinator), "");
189
+
190
+ assert.equal(notifications[0].type, "warning");
191
+ });
192
+ });
193
+
194
+ describe("executeBuild", () => {
195
+ it("builds the graph with default options", async () => {
196
+ const { ctx, notifications } = createMockContext();
197
+ let captured: unknown = null;
198
+ const backend = createMockBackend({
199
+ run: async (operation, options) => {
200
+ captured = options;
201
+ return createMockBackend().run(operation, options);
202
+ },
203
+ });
204
+ const customCoordinator = createTestCoordinator(backend);
205
+ await buildCommand.execute(ctx, getCoordinator(customCoordinator), "");
206
+
207
+ assert.deepEqual(captured, {
208
+ cwd: process.cwd(),
209
+ codeOnly: undefined,
210
+ update: undefined,
211
+ directed: undefined,
212
+ });
213
+ assert.equal(notifications[0].type, "info");
214
+ assert.ok(notifications[0].message.includes("ok: build"));
215
+ });
216
+
217
+ it("parses flags", async () => {
218
+ const { ctx } = createMockContext();
219
+ let captured: unknown = null;
220
+ const backend = createMockBackend({
221
+ run: async (operation, options) => {
222
+ captured = options;
223
+ return createMockBackend().run(operation, options);
224
+ },
225
+ });
226
+ const coordinator = createTestCoordinator(backend);
227
+ await buildCommand.execute(ctx, getCoordinator(coordinator), "--code-only --update --directed");
228
+
229
+ assert.deepEqual(captured, {
230
+ cwd: process.cwd(),
231
+ codeOnly: true,
232
+ update: true,
233
+ directed: true,
234
+ });
235
+ });
236
+
237
+ it("shows usage on unknown flags", async () => {
238
+ const { ctx, notifications } = createMockContext();
239
+ const coordinator = createTestCoordinator();
240
+ await buildCommand.execute(ctx, getCoordinator(coordinator), "--bad-flag");
241
+
242
+ assert.equal(notifications[0].type, "warning");
243
+ assert.ok(notifications[0].message.startsWith("Usage: /graphify-build"));
244
+ });
245
+
246
+ it("warns when project is not trusted", async () => {
247
+ const { ctx, notifications } = createMockContext({ isProjectTrusted: () => false });
248
+ const coordinator = createTestCoordinator();
249
+ await buildCommand.execute(ctx, getCoordinator(coordinator), "");
250
+
251
+ assert.equal(notifications[0].type, "warning");
252
+ assert.ok(notifications[0].message.includes("trust"));
253
+ });
254
+ });
255
+
256
+ describe("executeQuery", () => {
257
+ it("passes the question to the coordinator", async () => {
258
+ const { ctx, notifications } = createMockContext();
259
+ let captured: unknown = null;
260
+ const backend = createMockBackend({
261
+ run: async (operation, options) => {
262
+ captured = options;
263
+ return {
264
+ stdout: "answer",
265
+ stderr: "",
266
+ exitCode: 0,
267
+ durationMs: 1,
268
+ backend: "cli",
269
+ feature: operation,
270
+ };
271
+ },
272
+ });
273
+ const coordinator = createTestCoordinator(backend);
274
+ await queryCommand.execute(ctx, getCoordinator(coordinator), "how does auth work?");
275
+
276
+ assert.deepEqual(captured, { cwd: process.cwd(), question: "how does auth work?" });
277
+ assert.equal(notifications[0].message, "answer");
278
+ });
279
+
280
+ it("shows usage when question is empty", async () => {
281
+ const { ctx, notifications } = createMockContext();
282
+ const coordinator = createTestCoordinator();
283
+ await queryCommand.execute(ctx, getCoordinator(coordinator), " ");
284
+
285
+ assert.equal(notifications[0].type, "warning");
286
+ assert.ok(notifications[0].message.includes("Usage"));
287
+ });
288
+ });
289
+
290
+ describe("executePath", () => {
291
+ it("requires source and target", async () => {
292
+ const { ctx, notifications } = createMockContext();
293
+ const coordinator = createTestCoordinator();
294
+ await pathCommand.execute(ctx, getCoordinator(coordinator), "only-one");
295
+
296
+ assert.equal(notifications[0].type, "warning");
297
+ assert.ok(notifications[0].message.startsWith("Usage: /graphify-path"));
298
+ });
299
+
300
+ it("passes source and target to the coordinator", async () => {
301
+ const { ctx, notifications } = createMockContext();
302
+ let captured: unknown = null;
303
+ const backend = createMockBackend({
304
+ run: async (operation, options) => {
305
+ captured = options;
306
+ return createMockBackend().run(operation, options);
307
+ },
308
+ });
309
+ const coordinator = createTestCoordinator(backend);
310
+ await pathCommand.execute(ctx, getCoordinator(coordinator), "src/a.ts src/b.ts");
311
+
312
+ assert.deepEqual(captured, { cwd: process.cwd(), source: "src/a.ts", target: "src/b.ts" });
313
+ assert.ok(notifications[0].message.includes("ok: path"));
314
+ });
315
+
316
+ it("handles quoted arguments", async () => {
317
+ const { ctx } = createMockContext();
318
+ let captured: unknown = null;
319
+ const backend = createMockBackend({
320
+ run: async (operation, options) => {
321
+ captured = options;
322
+ return createMockBackend().run(operation, options);
323
+ },
324
+ });
325
+ const coordinator = createTestCoordinator(backend);
326
+ await pathCommand.execute(ctx, getCoordinator(coordinator), '"src/a.ts" "src/b.ts"');
327
+
328
+ assert.deepEqual(captured, { cwd: process.cwd(), source: "src/a.ts", target: "src/b.ts" });
329
+ });
330
+ });
331
+
332
+ describe("executeExplain", () => {
333
+ it("shows usage when node is empty", async () => {
334
+ const { ctx, notifications } = createMockContext();
335
+ const coordinator = createTestCoordinator();
336
+ await explainCommand.execute(ctx, getCoordinator(coordinator), " ");
337
+
338
+ assert.equal(notifications[0].type, "warning");
339
+ assert.ok(notifications[0].message.includes("Usage"));
340
+ });
341
+
342
+ it("passes the node to the coordinator", async () => {
343
+ const { ctx } = createMockContext();
344
+ let captured: unknown = null;
345
+ const backend = createMockBackend({
346
+ run: async (operation, options) => {
347
+ captured = options;
348
+ return createMockBackend().run(operation, options);
349
+ },
350
+ });
351
+ const coordinator = createTestCoordinator(backend);
352
+ await explainCommand.execute(ctx, getCoordinator(coordinator), "auth");
353
+
354
+ assert.deepEqual(captured, { cwd: process.cwd(), node: "auth" });
355
+ });
356
+ });
357
+
358
+ describe("executeAffected", () => {
359
+ it("shows usage when no files are given", async () => {
360
+ const { ctx, notifications } = createMockContext();
361
+ const coordinator = createTestCoordinator();
362
+ await affectedCommand.execute(ctx, getCoordinator(coordinator), " ");
363
+
364
+ assert.equal(notifications[0].type, "warning");
365
+ assert.ok(notifications[0].message.startsWith("Usage: /graphify-affected"));
366
+ });
367
+
368
+ it("rejects paths outside the project", async () => {
369
+ const { ctx, notifications } = createMockContext();
370
+ const coordinator = createTestCoordinator();
371
+ await affectedCommand.execute(ctx, getCoordinator(coordinator), "../escape.ts");
372
+
373
+ assert.equal(notifications[0].type, "error");
374
+ assert.ok(notifications[0].message.includes("outside the project"));
375
+ });
376
+
377
+ it("passes absolute paths to the coordinator", async () => {
378
+ const { ctx, notifications } = createMockContext();
379
+ let captured: unknown = null;
380
+ const backend = createMockBackend({
381
+ run: async (operation, options) => {
382
+ captured = options;
383
+ return createMockBackend().run(operation, options);
384
+ },
385
+ });
386
+ const coordinator = createTestCoordinator(backend);
387
+ await affectedCommand.execute(ctx, getCoordinator(coordinator), "src/index.ts");
388
+
389
+ const expected = { cwd: process.cwd(), files: [resolve(process.cwd(), "src/index.ts")] };
390
+ assert.deepEqual(captured, expected);
391
+ assert.ok(notifications[0].message.includes("ok: affected"));
392
+ });
393
+ });
394
+
395
+ describe("command error handling", () => {
396
+ it("handles GraphifyError from the coordinator", async () => {
397
+ const { ctx, notifications } = createMockContext();
398
+ const backend = createMockBackend({
399
+ run: async () => {
400
+ throw new GraphifyError("QUERY", "query failed");
401
+ },
402
+ });
403
+ const coordinator = createTestCoordinator(backend);
404
+ await queryCommand.execute(ctx, getCoordinator(coordinator), "x");
405
+
406
+ assert.equal(notifications[0].message, "query failed");
407
+ assert.equal(notifications[0].type, "warning");
408
+ });
409
+
410
+ it("handles unexpected errors as errors", async () => {
411
+ const { ctx, notifications } = createMockContext();
412
+ const backend = createMockBackend({
413
+ run: async () => {
414
+ throw new Error("boom");
415
+ },
416
+ });
417
+ const coordinator = createTestCoordinator(backend);
418
+ await queryCommand.execute(ctx, getCoordinator(coordinator), "x");
419
+
420
+ assert.equal(notifications[0].type, "error");
421
+ assert.equal(notifications[0].message, "boom");
422
+ });
423
+
424
+ it("truncates long output", async () => {
425
+ const { ctx, notifications } = createMockContext();
426
+ const longOutput = "a".repeat(TRUNCATION_TEST_OUTPUT_LENGTH);
427
+ const backend = createMockBackend({
428
+ run: async () => ({
429
+ stdout: longOutput,
430
+ stderr: "",
431
+ exitCode: 0,
432
+ durationMs: 1,
433
+ backend: "cli",
434
+ feature: "query",
435
+ }),
436
+ });
437
+ const coordinator = createTestCoordinator(backend);
438
+ await queryCommand.execute(ctx, getCoordinator(coordinator), "x");
439
+
440
+ assert.ok(notifications[0].message.endsWith("... (truncated)"));
441
+ assert.ok(notifications[0].message.length < TRUNCATION_TEST_ASSERTION_LIMIT);
442
+ });
443
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * /graphify-explain — explain a node or concept in the graph.
3
+ */
4
+
5
+ import type { GraphifyCapabilities } from "../backends/types.js";
6
+ import { formatErrorForCommand, formatResultForCommand } from "./format.js";
7
+ import { requireCoordinatorWithCapability } from "./helpers.js";
8
+ import type { CommandDefinition, CommandExecutor } from "./types.js";
9
+
10
+ const FEATURE: keyof GraphifyCapabilities = "explain";
11
+
12
+ const executeExplain: CommandExecutor = async (ctx, getCoordinator, args) => {
13
+ if (!ctx.hasUI) return;
14
+
15
+ const coordinator = requireCoordinatorWithCapability(
16
+ ctx,
17
+ getCoordinator,
18
+ FEATURE,
19
+ "The installed Graphify version does not support graph explanations.",
20
+ );
21
+ if (!coordinator) return;
22
+
23
+ const node = args.trim();
24
+ if (node === "") {
25
+ ctx.ui.notify("Usage: /graphify-explain <node>", "warning");
26
+ return;
27
+ }
28
+
29
+ try {
30
+ const result = await coordinator.explain({ cwd: ctx.cwd, node });
31
+ ctx.ui.notify(formatResultForCommand(result), "info");
32
+ } catch (error) {
33
+ const { message, severity } = formatErrorForCommand(error);
34
+ ctx.ui.notify(message, severity);
35
+ }
36
+ };
37
+
38
+ export const explainCommand: CommandDefinition = {
39
+ name: "graphify-explain",
40
+ label: "Explain node",
41
+ description: "Explain a node or concept in the graph",
42
+ feature: FEATURE,
43
+ usage: "<node>",
44
+ argMode: "input",
45
+ prompt: "Node to explain",
46
+ placeholder: "src/coordinator.ts:GraphifyCoordinator",
47
+ execute: executeExplain,
48
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Pure formatting helpers for command UI output.
3
+ */
4
+
5
+ import type { GraphifyResult } from "../backends/types.js";
6
+ import { GraphifyError } from "../errors.js";
7
+
8
+ const MAX_NOTIFICATION_LENGTH = 1000;
9
+ const TRUNCATION_SUFFIX = "\n... (truncated)";
10
+
11
+ /** Convert a successful backend result to a UI string, truncating if needed. */
12
+ export function formatResultForCommand(result: GraphifyResult): string {
13
+ const output = result.stdout.trim();
14
+ return truncate(output);
15
+ }
16
+
17
+ export interface FormattedError {
18
+ message: string;
19
+ severity: "warning" | "error";
20
+ }
21
+
22
+ /** Normalize an error into a severity-typed command message. */
23
+ export function formatErrorForCommand(error: unknown): FormattedError {
24
+ if (error instanceof GraphifyError) {
25
+ return { message: error.userMessage, severity: "warning" };
26
+ }
27
+
28
+ if (error instanceof Error) {
29
+ return { message: error.message, severity: "error" };
30
+ }
31
+
32
+ return { message: String(error), severity: "error" };
33
+ }
34
+
35
+ function truncate(text: string): string {
36
+ if (text.length <= MAX_NOTIFICATION_LENGTH) {
37
+ return text;
38
+ }
39
+ return text.slice(0, MAX_NOTIFICATION_LENGTH - TRUNCATION_SUFFIX.length) + TRUNCATION_SUFFIX;
40
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared helpers for Graphify command executors.
3
+ */
4
+
5
+ import type { GraphifyCapabilities } from "../backends/types.js";
6
+ import type { GraphifyCoordinator } from "../coordinator.js";
7
+ import { formatErrorForCommand } from "./format.js";
8
+ import type { CommandContext } from "./types.js";
9
+
10
+ /**
11
+ * Return a coordinator if it is initialized and supports the required feature.
12
+ * Otherwise notify the user and return null.
13
+ */
14
+ export function requireCoordinatorWithCapability(
15
+ ctx: CommandContext,
16
+ getCoordinator: () => GraphifyCoordinator | null,
17
+ feature: keyof GraphifyCapabilities,
18
+ unsupportedMessage: string,
19
+ ): GraphifyCoordinator | null {
20
+ const coordinator = getCoordinator();
21
+ if (!coordinator) {
22
+ ctx.ui.notify("Graphify is not initialized.", "warning");
23
+ return null;
24
+ }
25
+
26
+ if (!coordinator.supports(feature)) {
27
+ ctx.ui.notify(unsupportedMessage, "warning");
28
+ return null;
29
+ }
30
+
31
+ return coordinator;
32
+ }
33
+
34
+ /**
35
+ * Return a coordinator if it is initialized, regardless of capability.
36
+ */
37
+ export function requireCoordinator(
38
+ ctx: CommandContext,
39
+ getCoordinator: () => GraphifyCoordinator | null,
40
+ ): GraphifyCoordinator | null {
41
+ const coordinator = getCoordinator();
42
+ if (!coordinator) {
43
+ ctx.ui.notify("Graphify is not initialized.", "warning");
44
+ }
45
+ return coordinator;
46
+ }
47
+
48
+ /**
49
+ * Notify the user of a command result, handling usage errors distinctly from
50
+ * backend or unexpected errors.
51
+ */
52
+ export function notifyCommandError(ctx: CommandContext, error: unknown): void {
53
+ if (error instanceof Error && error.message.startsWith("Usage:")) {
54
+ ctx.ui.notify(error.message, "warning");
55
+ return;
56
+ }
57
+
58
+ const { message, severity } = formatErrorForCommand(error);
59
+ ctx.ui.notify(message, severity);
60
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Register all individual Graphify slash commands from the command registry.
3
+ */
4
+
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import type { CoordinatorProvider } from "../coordinator.js";
7
+ import { graphifyCommands } from "./registry.js";
8
+ import { toCommandContext } from "./types.js";
9
+
10
+ export function registerGraphifyCommands(
11
+ pi: ExtensionAPI,
12
+ getCoordinator: CoordinatorProvider,
13
+ ): void {
14
+ for (const command of graphifyCommands) {
15
+ pi.registerCommand(command.name, {
16
+ description: command.description,
17
+ handler: async (args, ctx) => {
18
+ await command.execute(toCommandContext(ctx), getCoordinator, args);
19
+ },
20
+ });
21
+ }
22
+ }