@selesai/code 0.13.21 → 0.13.23
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/CHANGELOG.md +18 -0
- package/dist/extensions/capability-gateway/index.ts +28 -29
- package/dist/extensions/capability-gateway/integration.test.ts +43 -6
- package/dist/extensions/capability-gateway/package.json +1 -0
- package/dist/extensions/package.json +2 -0
- package/dist/extensions/pi-graft/cli.test.ts +408 -0
- package/dist/extensions/pi-graft/cli.ts +547 -0
- package/dist/extensions/pi-graft/commands.test.ts +478 -0
- package/dist/extensions/pi-graft/commands.ts +392 -0
- package/dist/extensions/pi-graft/graft.integration.test.ts +153 -0
- package/dist/extensions/pi-graft/index.test.ts +167 -0
- package/dist/extensions/pi-graft/index.ts +521 -0
- package/dist/extensions/pi-graft/lifecycle.test.ts +609 -0
- package/dist/extensions/pi-graft/prompt.test.ts +213 -0
- package/dist/extensions/pi-graft/prompt.ts +216 -0
- package/dist/extensions/pi-graft/state.test.ts +258 -0
- package/dist/extensions/pi-graft/state.ts +335 -0
- package/dist/extensions/pi-graft/test-support.ts +199 -0
- package/dist/extensions/pi-graft/tools.test.ts +330 -0
- package/dist/extensions/pi-graft/tools.ts +333 -0
- package/dist/extensions/pi-graft/tsconfig.json +16 -0
- package/dist/extensions/pi-subagents/src/agents/builtin-agent-augmentations.ts +71 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +9 -3
- package/dist/extensions/pi-subagents/test/unit/builtin-agent-augmentations.test.ts +44 -0
- package/dist/extensions/tps.test.ts +9 -1
- package/dist/extensions/tps.ts +9 -3
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@selesai/code` will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.13.23] - 2026-09-13
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Bundled Graft code context.** Selesai now ships native integration for `@nanonets/graft`: it installs a compatible CLI when missing, builds a consent-gated local graph, injects bounded source-backed context for eligible coding turns, and exposes six read-only semantic retrieval tools plus `/graft` lifecycle commands. Structural builds stay local and offline; deep provider-backed builds require separate confirmation.
|
|
9
|
+
- **Graft-aware builtin subagents.** The `scout`, `reviewer`, `worker`, `delegate`, and `oracle` builtins automatically receive Graft's read-only code-context tools and child-only extension when Graft is loaded. Explicit user tool or extension overrides retain precedence.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- **Capability gateway Graft exemption.** Graft's code-context tools remain active rather than becoming dormant, preserving its pre-turn retrieval and precise follow-ups.
|
|
13
|
+
|
|
14
|
+
## [0.13.22] - 2026-09-12
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **Live TPS gate.** The live tokens-per-second status no longer reports implausible rates: a live reading now needs at least a 200 ms sample window and is dropped when the rate exceeds the plausible maximum, matching the gates the final measurement already applied.
|
|
18
|
+
- **Capability gateway hint noise.** The gateway no longer injects fuzzy skill recommendations or ambiguous-match catalog hints into the model context. A uniquely matched tool is still auto-activated, and optional capabilities remain discoverable through `capability_catalog`.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- **Capability catalog tool rows.** `capability_catalog` calls now render the search query and optional kind in the TUI instead of the bare tool name.
|
|
22
|
+
|
|
5
23
|
## [0.13.21] - 2026-09-11
|
|
6
24
|
|
|
7
25
|
### Fixed
|
|
@@ -6,17 +6,16 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Behavior:
|
|
8
8
|
* - On by default; disable via SELESAI_CAPABILITY_GATEWAY=0.
|
|
9
|
-
* - At session start, extension tools
|
|
10
|
-
*
|
|
11
|
-
* Built-in tools are never touched.
|
|
9
|
+
* - At session start, extension tools become dormant except the gateway's own
|
|
10
|
+
* tools and Graft's code-context tools: they stay registered but are removed
|
|
11
|
+
* from the active tool set. Built-in tools are never touched.
|
|
12
12
|
* - A compact catalog tool lists eligible tools/skills with one-line summaries.
|
|
13
13
|
* - capability_discover validates one catalogued tool and activates its native
|
|
14
14
|
* definition for the current agent run; capability_skill_show loads exactly
|
|
15
15
|
* the selected skill instructions.
|
|
16
|
-
* - A deterministic router
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* matches add a catalog hint; unrelated prompts are untouched.
|
|
16
|
+
* - A deterministic router activates a uniquely matched tool before the run.
|
|
17
|
+
* Skills and ambiguous matches remain discoverable through the catalog
|
|
18
|
+
* without injecting fuzzy hints into the model context.
|
|
20
19
|
* - Temporary activations reset at agent_settled, restoring the baseline
|
|
21
20
|
* active-tool set.
|
|
22
21
|
* - The system-prompt skill index is replaced by a compact capability
|
|
@@ -28,6 +27,7 @@ import { readFileSync } from "node:fs";
|
|
|
28
27
|
import { dirname } from "node:path";
|
|
29
28
|
import { stripFrontmatter, type ExtensionAPI, type ToolInfo } from "@selesai/code";
|
|
30
29
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
30
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
31
31
|
import { Type } from "typebox";
|
|
32
32
|
import {
|
|
33
33
|
buildSkillCatalog,
|
|
@@ -40,6 +40,17 @@ import {
|
|
|
40
40
|
export const GATEWAY_ENV = "SELESAI_CAPABILITY_GATEWAY";
|
|
41
41
|
export const GATEWAY_TOOLS = new Set(["capability_catalog", "capability_discover", "capability_skill_show"]);
|
|
42
42
|
|
|
43
|
+
// Graft supplies pre-turn hybrid context and must remain callable for precise
|
|
44
|
+
// follow-ups; making it dormant defeats both paths.
|
|
45
|
+
const ALWAYS_ACTIVE_EXTENSION_TOOLS = new Set([
|
|
46
|
+
"graft_check_freshness",
|
|
47
|
+
"graft_file_api",
|
|
48
|
+
"graft_find_all",
|
|
49
|
+
"graft_find_code",
|
|
50
|
+
"graft_repo_map",
|
|
51
|
+
"graft_trace_calls",
|
|
52
|
+
]);
|
|
53
|
+
|
|
43
54
|
export const CAPABILITY_INSTRUCTION = `Optional capabilities (extension tools and skills) are not listed here by default. To use one:
|
|
44
55
|
- Search the compact catalog with capability_catalog (kind: "tool" or "skill", natural-language query) when no active tool fits or a specialized integration/workflow is requested.
|
|
45
56
|
- Activate a catalogued tool with capability_discover, then call it normally on the next turn.
|
|
@@ -53,7 +64,7 @@ function isEnabled(): boolean {
|
|
|
53
64
|
function eligibleTools(pi: ExtensionAPI): ToolInfo[] {
|
|
54
65
|
return pi
|
|
55
66
|
.getAllTools()
|
|
56
|
-
.filter((tool) => !GATEWAY_TOOLS.has(tool.name) && !BUILTIN_TOOL_NAMES.has(tool.name));
|
|
67
|
+
.filter((tool) => !GATEWAY_TOOLS.has(tool.name) && !ALWAYS_ACTIVE_EXTENSION_TOOLS.has(tool.name) && !BUILTIN_TOOL_NAMES.has(tool.name));
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
function catalogEntries(pi: ExtensionAPI): CatalogEntry[] {
|
|
@@ -177,6 +188,13 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
|
|
|
177
188
|
details: { count: shown.length, total: filtered.length },
|
|
178
189
|
};
|
|
179
190
|
},
|
|
191
|
+
renderCall(args, theme) {
|
|
192
|
+
const query = typeof args.query === "string" && args.query.length > 0 ? args.query : "(all)";
|
|
193
|
+
let text = theme.fg("toolTitle", theme.bold("capability_catalog "));
|
|
194
|
+
text += theme.fg("accent", `"${query}"`);
|
|
195
|
+
if (args.kind) text += " " + theme.fg("muted", args.kind);
|
|
196
|
+
return new Text(text, 0, 0);
|
|
197
|
+
},
|
|
180
198
|
});
|
|
181
199
|
|
|
182
200
|
pi.registerTool({
|
|
@@ -276,27 +294,8 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
|
|
|
276
294
|
emitTelemetry(pi, "route_activate", { tool: result.entry.name });
|
|
277
295
|
return undefined;
|
|
278
296
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
return {
|
|
282
|
-
message: {
|
|
283
|
-
customType: "capability-gateway-hint",
|
|
284
|
-
content: `The request matches the optional skill "${result.entry.name}". Load it with capability_skill_show before applying it.`,
|
|
285
|
-
display: false,
|
|
286
|
-
},
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
if (result.action === "hint" && result.candidates) {
|
|
290
|
-
const names = result.candidates.map((c) => c.name).join(", ");
|
|
291
|
-
emitTelemetry(pi, "route_hint", { candidates: result.candidates.map((c) => c.name) });
|
|
292
|
-
return {
|
|
293
|
-
message: {
|
|
294
|
-
customType: "capability-gateway-hint",
|
|
295
|
-
content: `The request may match optional capabilities: ${names}. Search capability_catalog to confirm before selecting.`,
|
|
296
|
-
display: false,
|
|
297
|
-
},
|
|
298
|
-
};
|
|
299
|
-
}
|
|
297
|
+
// Do not inject fuzzy recommendations or catalog hints into the model
|
|
298
|
+
// context. It can discover capabilities when it actually needs one.
|
|
300
299
|
return undefined;
|
|
301
300
|
});
|
|
302
301
|
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
const EXTENSIONS_DIR = fileURLToPath(new URL("../../", import.meta.url));
|
|
17
17
|
const GATEWAY_DIR = fileURLToPath(new URL(".", import.meta.url));
|
|
18
18
|
const GREP_APP_DIR = fileURLToPath(new URL("../grep-app", import.meta.url));
|
|
19
|
+
const GRAFT_DIR = fileURLToPath(new URL("../pi-graft", import.meta.url));
|
|
19
20
|
const INLINE_SKILLS_FILE = fileURLToPath(new URL("../inline-skills.ts", import.meta.url));
|
|
20
21
|
|
|
21
22
|
interface Harness {
|
|
@@ -134,6 +135,20 @@ describe("capability gateway integration", () => {
|
|
|
134
135
|
expect(active).toContain("capability_catalog");
|
|
135
136
|
});
|
|
136
137
|
|
|
138
|
+
it("keeps all Graft tools active for hybrid context and precise follow-ups", async () => {
|
|
139
|
+
const h = await createGatewaySession({ enabled: true, extensions: [GATEWAY_DIR, GRAFT_DIR] });
|
|
140
|
+
harnesses.push(h);
|
|
141
|
+
const active = h.session.getActiveToolNames();
|
|
142
|
+
for (const name of [
|
|
143
|
+
"graft_check_freshness",
|
|
144
|
+
"graft_file_api",
|
|
145
|
+
"graft_find_all",
|
|
146
|
+
"graft_find_code",
|
|
147
|
+
"graft_repo_map",
|
|
148
|
+
"graft_trace_calls",
|
|
149
|
+
]) expect(active).toContain(name);
|
|
150
|
+
});
|
|
151
|
+
|
|
137
152
|
it("keeps all-visible behavior when disabled (compatibility mode)", async () => {
|
|
138
153
|
const h = await createGatewaySession({ enabled: false });
|
|
139
154
|
harnesses.push(h);
|
|
@@ -175,6 +190,19 @@ describe("capability gateway integration", () => {
|
|
|
175
190
|
expect(result.details).toMatchObject({ count: 1 });
|
|
176
191
|
});
|
|
177
192
|
|
|
193
|
+
it("renders the query and kind in the tool call line", async () => {
|
|
194
|
+
const h = await createGatewaySession({ enabled: true });
|
|
195
|
+
harnesses.push(h);
|
|
196
|
+
const catalog = h.session.getToolDefinition("capability_catalog");
|
|
197
|
+
const theme = { fg: (_name: string, text: string) => text, bold: (text: string) => text } as never;
|
|
198
|
+
const draw = (args: unknown) =>
|
|
199
|
+
(catalog!.renderCall!(args as never, theme, {} as never) as { render(width: number): string[] }).render(120).join("\n");
|
|
200
|
+
|
|
201
|
+
expect(draw({ query: "ponytail-debt", kind: "skill" })).toContain('"ponytail-debt"');
|
|
202
|
+
expect(draw({ query: "ponytail-debt", kind: "skill" })).toContain("skill");
|
|
203
|
+
expect(draw({})).toContain("(all)");
|
|
204
|
+
});
|
|
205
|
+
|
|
178
206
|
it("activates a discovered tool for the run and resets after agent_settled", async () => {
|
|
179
207
|
const h = await createGatewaySession({ enabled: true });
|
|
180
208
|
harnesses.push(h);
|
|
@@ -212,26 +240,35 @@ describe("capability gateway integration", () => {
|
|
|
212
240
|
expect(h.session.getActiveToolNames()).toContain("grep_app_search");
|
|
213
241
|
});
|
|
214
242
|
|
|
215
|
-
it("does not
|
|
243
|
+
it("does not inject fuzzy skill recommendations", async () => {
|
|
216
244
|
const h = await createGatewaySession({
|
|
217
245
|
enabled: true,
|
|
218
246
|
withSkill: true,
|
|
219
247
|
extensions: [GATEWAY_DIR, GREP_APP_DIR],
|
|
220
248
|
});
|
|
221
249
|
harnesses.push(h);
|
|
222
|
-
const
|
|
223
|
-
const result = await runner.emitBeforeAgentStart(
|
|
250
|
+
const result = await h.session.extensionRunner.emitBeforeAgentStart(
|
|
224
251
|
"do research on this topic",
|
|
225
252
|
undefined,
|
|
226
253
|
h.session.systemPrompt,
|
|
227
254
|
{ cwd: process.cwd() } as never,
|
|
228
255
|
);
|
|
229
|
-
|
|
230
|
-
// into the prompt and the skill is not auto-activated.
|
|
231
|
-
expect(result?.messages?.[0]?.content).toContain("research");
|
|
256
|
+
expect(result).toBeUndefined();
|
|
232
257
|
expect(h.session.systemPrompt).not.toContain("Research instructions body.");
|
|
233
258
|
});
|
|
234
259
|
|
|
260
|
+
it("does not inject catalog hints for ambiguous tool matches", async () => {
|
|
261
|
+
const h = await createGatewaySession({ enabled: true, extensions: [GATEWAY_DIR, GREP_APP_DIR] });
|
|
262
|
+
harnesses.push(h);
|
|
263
|
+
const result = await h.session.extensionRunner.emitBeforeAgentStart(
|
|
264
|
+
"grep app",
|
|
265
|
+
undefined,
|
|
266
|
+
h.session.systemPrompt,
|
|
267
|
+
{ cwd: process.cwd() } as never,
|
|
268
|
+
);
|
|
269
|
+
expect(result).toBeUndefined();
|
|
270
|
+
});
|
|
271
|
+
|
|
235
272
|
it("does not recommend capability_skill_show for an inline-loaded $skill", async () => {
|
|
236
273
|
const h = await createGatewaySession({
|
|
237
274
|
enabled: true,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"name": "@selesai/bundled-extensions",
|
|
3
3
|
"version": "0.0.0",
|
|
4
4
|
"private": true,
|
|
5
|
+
"type": "module",
|
|
5
6
|
"description": "Built-in extensions shipped with @selesai/code, loaded via additionalExtensionPaths at boot.",
|
|
6
7
|
"pi": {
|
|
7
8
|
"extensions": [
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
"./inline-skills.ts",
|
|
20
21
|
"./rtk.ts",
|
|
21
22
|
"./tokenin-onboarding.ts",
|
|
23
|
+
"./pi-graft",
|
|
22
24
|
"./undo.ts",
|
|
23
25
|
"./pi-subagents",
|
|
24
26
|
"./pi-tool-display",
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
applyTelemetryDefault,
|
|
4
|
+
askReferences,
|
|
5
|
+
boundText,
|
|
6
|
+
compareVersions,
|
|
7
|
+
DEFAULT_FIND_LIMIT,
|
|
8
|
+
GRAFT_INSTALL_SPEC,
|
|
9
|
+
graftArgs,
|
|
10
|
+
graftCommand,
|
|
11
|
+
INSTALL_COMMAND,
|
|
12
|
+
INSTALL_TIMEOUT_MS,
|
|
13
|
+
installGraft,
|
|
14
|
+
graftFailureMessage,
|
|
15
|
+
isCompatibleVersion,
|
|
16
|
+
MAX_GRAFT_VERSION_EXCLUSIVE,
|
|
17
|
+
MIN_GRAFT_VERSION,
|
|
18
|
+
parseAskJson,
|
|
19
|
+
parsePointer,
|
|
20
|
+
npmCommand,
|
|
21
|
+
parseVersion,
|
|
22
|
+
probeGraft,
|
|
23
|
+
renderAsk,
|
|
24
|
+
runGraft,
|
|
25
|
+
TELEMETRY_ENV,
|
|
26
|
+
type GraftOp,
|
|
27
|
+
type GraftRun,
|
|
28
|
+
} from "./cli.ts";
|
|
29
|
+
import { FAILED, makePi, OK, type StubExecResult } from "./test-support.ts";
|
|
30
|
+
|
|
31
|
+
function execReturning(result: StubExecResult) {
|
|
32
|
+
return { ...result };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("graftCommand", () => {
|
|
36
|
+
it("invokes the executable directly off Windows", () => {
|
|
37
|
+
const original = process.platform;
|
|
38
|
+
if (original === "win32") return;
|
|
39
|
+
expect(graftCommand(["ask", "hi"])).toEqual({ command: "graft", args: ["ask", "hi"] });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("routes through cmd.exe on win32 so the .cmd shim can be spawned without a shell", () => {
|
|
43
|
+
const original = process.platform;
|
|
44
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
45
|
+
try {
|
|
46
|
+
expect(graftCommand(["build"])).toEqual({ command: "cmd", args: ["/c", "graft", "build"] });
|
|
47
|
+
} finally {
|
|
48
|
+
Object.defineProperty(process, "platform", { value: original });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("installGraft", () => {
|
|
54
|
+
it("runs the compatible global npm install without a shell off Windows", async () => {
|
|
55
|
+
const { pi } = makePi(async () => execReturning(OK));
|
|
56
|
+
const run = await installGraft(pi.exec as never, "/repo");
|
|
57
|
+
expect(GRAFT_INSTALL_SPEC).toBe("@nanonets/graft@^0.18");
|
|
58
|
+
expect(INSTALL_COMMAND).toBe("npm install -g @nanonets/graft@^0.18");
|
|
59
|
+
const [command, args, options] = pi.exec.mock.calls[0] as [string, string[], { cwd: string; timeout: number }];
|
|
60
|
+
expect(command).toBe(process.platform === "win32" ? "cmd" : "npm");
|
|
61
|
+
expect(args).toContain("install");
|
|
62
|
+
expect(args).toContain(GRAFT_INSTALL_SPEC);
|
|
63
|
+
expect(options).toMatchObject({ cwd: "/repo", timeout: INSTALL_TIMEOUT_MS });
|
|
64
|
+
expect(run.code).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("routes npm through cmd.exe on win32", () => {
|
|
68
|
+
const original = process.platform;
|
|
69
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
70
|
+
try {
|
|
71
|
+
expect(npmCommand(["install", "-g", GRAFT_INSTALL_SPEC])).toEqual({
|
|
72
|
+
command: "cmd",
|
|
73
|
+
args: ["/c", "npm", "install", "-g", GRAFT_INSTALL_SPEC],
|
|
74
|
+
});
|
|
75
|
+
} finally {
|
|
76
|
+
Object.defineProperty(process, "platform", { value: original });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("normalizes install spawn failures", async () => {
|
|
81
|
+
const { pi } = makePi(async () => {
|
|
82
|
+
throw new Error("spawn npm ENOENT");
|
|
83
|
+
});
|
|
84
|
+
const run = await installGraft(pi.exec as never, "/repo");
|
|
85
|
+
expect(run.code).toBe(1);
|
|
86
|
+
expect(run.stderr).toContain("ENOENT");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("version handling", () => {
|
|
91
|
+
it("reads a semantic version out of arbitrary CLI output", () => {
|
|
92
|
+
expect(parseVersion("0.18.0")).toBe("0.18.0");
|
|
93
|
+
expect(parseVersion("graft v0.18.3\nlatest on npm: 0.19.0")).toBe("0.18.3");
|
|
94
|
+
expect(parseVersion("0.18.0-beta.2")).toBe("0.18.0-beta.2");
|
|
95
|
+
expect(parseVersion("no version here")).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("compares numeric segments, ignoring suffixes", () => {
|
|
99
|
+
expect(compareVersions("0.18.0", "0.18.0")).toBe(0);
|
|
100
|
+
expect(compareVersions("0.17.9", "0.18.0")).toBeLessThan(0);
|
|
101
|
+
expect(compareVersions("0.18.1-beta.1", "0.18.0")).toBeGreaterThan(0);
|
|
102
|
+
expect(compareVersions("1.0.0", "0.99.99")).toBeGreaterThan(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("accepts the verified range and rejects the boundaries", () => {
|
|
106
|
+
expect(isCompatibleVersion(MIN_GRAFT_VERSION)).toBe(true);
|
|
107
|
+
expect(isCompatibleVersion("0.18.4")).toBe(true);
|
|
108
|
+
expect(isCompatibleVersion("0.99.0")).toBe(true);
|
|
109
|
+
expect(isCompatibleVersion("0.17.9")).toBe(false);
|
|
110
|
+
expect(isCompatibleVersion(MAX_GRAFT_VERSION_EXCLUSIVE)).toBe(false);
|
|
111
|
+
expect(isCompatibleVersion("1.2.0")).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("probeGraft", () => {
|
|
116
|
+
it("accepts a supported version and reports it", async () => {
|
|
117
|
+
const { pi } = makePi(async () => execReturning({ ...OK, stdout: "0.18.0\n" }));
|
|
118
|
+
const probe = await probeGraft(pi.exec as never, "/repo");
|
|
119
|
+
expect(probe).toMatchObject({ kind: "ok", version: "0.18.0" });
|
|
120
|
+
if (probe.kind === "ok") expect(probe.argv.at(-1)).toBe("--version");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("reports a missing executable when the spawn throws", async () => {
|
|
124
|
+
const { pi } = makePi(async () => {
|
|
125
|
+
throw new Error("spawn graft ENOENT");
|
|
126
|
+
});
|
|
127
|
+
const probe = await probeGraft(pi.exec as never, "/repo");
|
|
128
|
+
expect(probe.kind).toBe("missing");
|
|
129
|
+
if (probe.kind === "missing") expect(probe.detail).toContain("ENOENT");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("reports a missing executable when the probe is killed", async () => {
|
|
133
|
+
const { pi } = makePi(async () => execReturning({ ...OK, killed: true }));
|
|
134
|
+
const probe = await probeGraft(pi.exec as never, "/repo");
|
|
135
|
+
expect(probe.kind).toBe("missing");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("refuses to accept an out-of-range version", async () => {
|
|
139
|
+
const { pi } = makePi(async () => execReturning({ ...OK, stdout: "0.4.0\n" }));
|
|
140
|
+
const probe = await probeGraft(pi.exec as never, "/repo");
|
|
141
|
+
expect(probe.kind).toBe("incompatible");
|
|
142
|
+
if (probe.kind === "incompatible") {
|
|
143
|
+
expect(probe.version).toBe("0.4.0");
|
|
144
|
+
expect(probe.detail).toContain(MIN_GRAFT_VERSION);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("treats an unreadable version as incompatible rather than assuming compatibility", async () => {
|
|
149
|
+
const { pi } = makePi(async () => execReturning({ ...OK, stdout: "graft (unknown build)\n" }));
|
|
150
|
+
const probe = await probeGraft(pi.exec as never, "/repo");
|
|
151
|
+
expect(probe.kind).toBe("incompatible");
|
|
152
|
+
if (probe.kind === "incompatible") expect(probe.version).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("graftArgs", () => {
|
|
157
|
+
it("maps every read-only operation to its documented CLI invocation", () => {
|
|
158
|
+
const cases: [GraftOp, string[]][] = [
|
|
159
|
+
[{ kind: "find-code", question: "how does auth work" }, ["ask", "how does auth work", "--source", "--json"]],
|
|
160
|
+
[
|
|
161
|
+
{ kind: "find-code", question: "auth", limit: 3, in: "packages/api" },
|
|
162
|
+
["ask", "auth", "--source", "--json", "-n", "3", "--in", "packages/api"],
|
|
163
|
+
],
|
|
164
|
+
[{ kind: "file-api", path: "src/app.ts" }, ["skeleton", "src/app.ts"]],
|
|
165
|
+
[{ kind: "trace-calls", symbol: "build", direction: "in" }, ["callers", "build"]],
|
|
166
|
+
[
|
|
167
|
+
{ kind: "trace-calls", symbol: "build", direction: "out", depth: 3 },
|
|
168
|
+
["callers", "build", "--direction", "out", "-d", "3"],
|
|
169
|
+
],
|
|
170
|
+
[
|
|
171
|
+
{ kind: "find-all", pattern: "TODO", ignoreCase: true, fixed: true, in: "src" },
|
|
172
|
+
["grep", "TODO", "-i", "--fixed", "--in", "src"],
|
|
173
|
+
],
|
|
174
|
+
[{ kind: "repo-map", maxDirs: 12 }, ["map", "--max-dirs", "12"]],
|
|
175
|
+
[{ kind: "check-freshness" }, ["check", "--json"]],
|
|
176
|
+
[{ kind: "build", deep: false }, ["build"]],
|
|
177
|
+
[{ kind: "build", deep: true }, ["build", "--deep"]],
|
|
178
|
+
];
|
|
179
|
+
for (const [op, expected] of cases) expect(graftArgs(op)).toEqual(expected);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("keeps the default depth implicit so the CLI's own default applies", () => {
|
|
183
|
+
expect(graftArgs({ kind: "trace-calls", symbol: "x", direction: "in", depth: 1 })).toEqual(["callers", "x"]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("passes hostile input as one verbatim argv entry, never as shell text", () => {
|
|
187
|
+
const question = 'rm -rf / && echo "pwned"; `whoami`';
|
|
188
|
+
// The whole string survives as exactly one argv element: there is no shell to
|
|
189
|
+
// reinterpret the metacharacters, and nothing splices it into another argument.
|
|
190
|
+
expect(graftArgs({ kind: "find-code", question })).toEqual(["ask", question, "--source", "--json"]);
|
|
191
|
+
|
|
192
|
+
const hostilePattern = '"; rm -rf $HOME; "';
|
|
193
|
+
expect(graftArgs({ kind: "find-all", pattern: hostilePattern })).toEqual(["grep", hostilePattern]);
|
|
194
|
+
|
|
195
|
+
expect(graftArgs({ kind: "file-api", path: "src/with space/and;rm.ts" })).toEqual([
|
|
196
|
+
"skeleton",
|
|
197
|
+
"src/with space/and;rm.ts",
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("runGraft", () => {
|
|
203
|
+
it("runs in the resolved repository root and reports the full argv", async () => {
|
|
204
|
+
const { pi } = makePi(async () => execReturning({ ...OK, stdout: "ok" }));
|
|
205
|
+
const run = await runGraft(pi.exec as never, "/repo/root", { kind: "repo-map" });
|
|
206
|
+
expect(pi.exec).toHaveBeenCalledTimes(1);
|
|
207
|
+
const [command, args, options] = pi.exec.mock.calls[0] as [string, string[], { cwd: string; timeout: number }];
|
|
208
|
+
expect(command).toBe(process.platform === "win32" ? "cmd" : "graft");
|
|
209
|
+
expect(args).toContain("map");
|
|
210
|
+
expect(options.cwd).toBe("/repo/root");
|
|
211
|
+
expect(options.timeout).toBe(60_000);
|
|
212
|
+
expect(run.cwd).toBe("/repo/root");
|
|
213
|
+
expect(run.argv.at(-1)).toBe("map");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("uses the long build timeout for builds", async () => {
|
|
217
|
+
const { pi } = makePi(async () => execReturning(OK));
|
|
218
|
+
await runGraft(pi.exec as never, "/repo", { kind: "build", deep: true });
|
|
219
|
+
const [, , options] = pi.exec.mock.calls[0] as [string, string[], { timeout: number }];
|
|
220
|
+
expect(options.timeout).toBe(1_800_000);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("distinguishes a caller abort from a timeout", async () => {
|
|
224
|
+
const { pi } = makePi(async () => execReturning({ ...OK, killed: true }));
|
|
225
|
+
const controller = new AbortController();
|
|
226
|
+
controller.abort();
|
|
227
|
+
const cancelled = await runGraft(pi.exec as never, "/repo", { kind: "repo-map" }, { signal: controller.signal });
|
|
228
|
+
expect(cancelled.cancelled).toBe(true);
|
|
229
|
+
expect(cancelled.timedOut).toBe(false);
|
|
230
|
+
|
|
231
|
+
const noSignal = await runGraft(pi.exec as never, "/repo", { kind: "repo-map" });
|
|
232
|
+
expect(noSignal.timedOut).toBe(true);
|
|
233
|
+
expect(noSignal.cancelled).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("normalizes a spawn failure into a failed run instead of throwing", async () => {
|
|
237
|
+
const { pi } = makePi(async () => {
|
|
238
|
+
throw new Error("spawn graft ENOENT");
|
|
239
|
+
});
|
|
240
|
+
const run = await runGraft(pi.exec as never, "/repo", { kind: "repo-map" });
|
|
241
|
+
expect(run.code).toBe(1);
|
|
242
|
+
expect(run.stderr).toContain("ENOENT");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("graftFailureMessage", () => {
|
|
247
|
+
const base: GraftRun = {
|
|
248
|
+
op: { kind: "repo-map" },
|
|
249
|
+
argv: ["graft", "map"],
|
|
250
|
+
cwd: "/repo",
|
|
251
|
+
code: 1,
|
|
252
|
+
stdout: "",
|
|
253
|
+
stderr: "",
|
|
254
|
+
killed: false,
|
|
255
|
+
cancelled: false,
|
|
256
|
+
timedOut: false,
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
it("names the command and its exit status, taking the first stderr line", () => {
|
|
260
|
+
expect(graftFailureMessage({ ...base, stderr: "boom\ndetails" })).toBe("graft map failed (exit 1): boom");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("falls back to stdout when stderr is empty", () => {
|
|
264
|
+
expect(graftFailureMessage({ ...base, stdout: "✗ no graft/ here" })).toContain("no graft/ here");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("reports cancellation and timeouts distinctly", () => {
|
|
268
|
+
expect(graftFailureMessage({ ...base, cancelled: true })).toContain("cancelled");
|
|
269
|
+
expect(graftFailureMessage({ ...base, timedOut: true })).toContain("timed out");
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("boundText", () => {
|
|
274
|
+
it("passes short output through untouched", () => {
|
|
275
|
+
const bounded = boundText("one\ntwo");
|
|
276
|
+
expect(bounded).toEqual({ text: "one\ntwo", truncated: false, truncatedBy: null, totalBytes: 7 });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("truncates by lines and says how to see the rest", () => {
|
|
280
|
+
const bounded = boundText(Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n"), { maxLines: 10 });
|
|
281
|
+
expect(bounded.truncated).toBe(true);
|
|
282
|
+
expect(bounded.truncatedBy).toBe("lines");
|
|
283
|
+
expect(bounded.text).toContain("line 0");
|
|
284
|
+
expect(bounded.text).not.toContain("line 20");
|
|
285
|
+
expect(bounded.text).toContain("[graft] output truncated (10 of 50 lines)");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("truncates by bytes and reports the byte ceiling", () => {
|
|
289
|
+
const bounded = boundText("x".repeat(500), { maxBytes: 100, maxLines: 100 });
|
|
290
|
+
expect(bounded.truncated).toBe(true);
|
|
291
|
+
expect(bounded.truncatedBy).toBe("bytes");
|
|
292
|
+
expect(bounded.text).toContain("of 500 bytes");
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("parseAskJson", () => {
|
|
297
|
+
const valid = JSON.stringify({
|
|
298
|
+
query: "auth",
|
|
299
|
+
mode: "lexical",
|
|
300
|
+
hits: [
|
|
301
|
+
{ kind: "symbol", title: "verify · function", pointer: "src/auth.ts:L10-L20", snippet: "checks", score: 3, code: "x" },
|
|
302
|
+
{ kind: "concept", title: "Auth", pointer: "src/auth.ts", related: ["a"], score: 1 },
|
|
303
|
+
],
|
|
304
|
+
coverage: 0.6,
|
|
305
|
+
coverageStrong: 0.5,
|
|
306
|
+
saved: { files: 2, baselineChars: 4000 },
|
|
307
|
+
rules: [{ text: "always verify" }],
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("reads the documented hit shape", () => {
|
|
311
|
+
const payload = parseAskJson(valid);
|
|
312
|
+
expect(payload?.mode).toBe("lexical");
|
|
313
|
+
expect(payload?.coverage).toBe(0.6);
|
|
314
|
+
expect(payload?.saved).toEqual({ files: 2, baselineChars: 4000 });
|
|
315
|
+
expect(payload?.hits).toHaveLength(2);
|
|
316
|
+
expect(payload?.hits[0]).toMatchObject({ pointer: "src/auth.ts:L10-L20", code: "x" });
|
|
317
|
+
expect(payload?.rules).toEqual([{ id: undefined, text: "always verify" }]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("returns undefined for anything it cannot trust", () => {
|
|
321
|
+
expect(parseAskJson("not json")).toBeUndefined();
|
|
322
|
+
expect(parseAskJson("[]")).toBeUndefined();
|
|
323
|
+
expect(parseAskJson(JSON.stringify({ query: "x" }))).toBeUndefined();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("skips malformed hits instead of failing the whole parse", () => {
|
|
327
|
+
const payload = parseAskJson(JSON.stringify({ hits: [{ title: "no pointer" }, { pointer: "src/a.ts" }, 7] }));
|
|
328
|
+
expect(payload?.hits).toHaveLength(1);
|
|
329
|
+
expect(payload?.hits[0]?.title).toBe("src/a.ts");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("drops a malformed saved block rather than surfacing wrong numbers", () => {
|
|
333
|
+
expect(parseAskJson(JSON.stringify({ hits: [], saved: { files: "two" } }))?.saved).toBeUndefined();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe("parsePointer", () => {
|
|
338
|
+
it("splits a file:span pointer", () => {
|
|
339
|
+
expect(parsePointer("src/a.ts:L1-L9")).toEqual({ path: "src/a.ts", span: "L1-L9" });
|
|
340
|
+
expect(parsePointer("src/a.ts:12")).toEqual({ path: "src/a.ts:12" });
|
|
341
|
+
expect(parsePointer("src/a.ts")).toEqual({ path: "src/a.ts" });
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe("renderAsk", () => {
|
|
346
|
+
it("renders a compact ranked pack with pointers, snippets, source and receipts", () => {
|
|
347
|
+
const payload = parseAskJson(
|
|
348
|
+
JSON.stringify({
|
|
349
|
+
mode: "lexical",
|
|
350
|
+
note: "ranked by relevance",
|
|
351
|
+
hits: [
|
|
352
|
+
{ kind: "symbol", title: "verify · function", pointer: "src/auth.ts:L10-L20", snippet: "checks the token", code: "return true;" },
|
|
353
|
+
{ kind: "caller", title: "login", pointer: "src/login.ts:L1-L4", snippet: "", relation: "calls" },
|
|
354
|
+
],
|
|
355
|
+
saved: { files: 2, baselineChars: 4000 },
|
|
356
|
+
rules: [{ text: "always verify" }],
|
|
357
|
+
}),
|
|
358
|
+
)!;
|
|
359
|
+
const text = renderAsk(payload);
|
|
360
|
+
expect(text).toContain("graft ask (lexical) — 2 hit(s)");
|
|
361
|
+
expect(text).toContain("1. verify · function — src/auth.ts:L10-L20");
|
|
362
|
+
expect(text).toContain("return true;");
|
|
363
|
+
expect(text).toContain("2. login (calls) — src/login.ts:L1-L4");
|
|
364
|
+
expect(text).toContain("governing rules:");
|
|
365
|
+
expect(text).toContain("reading them instead would cost ~1000 tokens");
|
|
366
|
+
expect(askReferences(payload)).toEqual([
|
|
367
|
+
{ path: "src/auth.ts", span: "L10-L20" },
|
|
368
|
+
{ path: "src/login.ts", span: "L1-L4" },
|
|
369
|
+
]);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("labels a scoped hit with its sub-project", () => {
|
|
373
|
+
const payload = parseAskJson(JSON.stringify({ hits: [{ pointer: "src/a.ts", scope: "packages/api" }] }))!;
|
|
374
|
+
expect(renderAsk(payload)).toContain("packages/api/src/a.ts");
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("applyTelemetryDefault", () => {
|
|
379
|
+
it("sets DO_NOT_TRACK and restores the prior absence on session shutdown", () => {
|
|
380
|
+
const env: NodeJS.ProcessEnv = {};
|
|
381
|
+
const handle = applyTelemetryDefault(env);
|
|
382
|
+
expect(env[TELEMETRY_ENV]).toBe("1");
|
|
383
|
+
handle.restore();
|
|
384
|
+
expect(env[TELEMETRY_ENV]).toBeUndefined();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("leaves an explicit user preference alone", () => {
|
|
388
|
+
const env: NodeJS.ProcessEnv = { [TELEMETRY_ENV]: "0" };
|
|
389
|
+
const handle = applyTelemetryDefault(env);
|
|
390
|
+
expect(env[TELEMETRY_ENV]).toBe("0");
|
|
391
|
+
handle.restore();
|
|
392
|
+
expect(env[TELEMETRY_ENV]).toBe("0");
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe("modest defaults", () => {
|
|
397
|
+
it("keeps the ask limit aligned with the CLI default", () => {
|
|
398
|
+
expect(DEFAULT_FIND_LIMIT).toBe(8);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("never calls the executable through a shell", async () => {
|
|
402
|
+
const { pi } = makePi(async () => execReturning(FAILED));
|
|
403
|
+
await runGraft(pi.exec as never, "/repo", { kind: "find-all", pattern: "a|b" });
|
|
404
|
+
const [command] = pi.exec.mock.calls[0] as [string];
|
|
405
|
+
expect(["graft", "cmd"]).toContain(command);
|
|
406
|
+
expect(vi.isMockFunction(pi.exec)).toBe(true);
|
|
407
|
+
});
|
|
408
|
+
});
|