287 lines
9 KiB
TypeScript
287 lines
9 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { join, relative, resolve, sep } from "node:path";
|
|
import { describe, it } from "vitest";
|
|
|
|
// ─── Inline the pure functions under test to avoid import-chain issues ───────
|
|
// These are copied from packages/pi-coding-agent/src/core/resource-loader.ts
|
|
// (detectExtensionConflicts + extractExtensionKey). The test validates the
|
|
// algorithm; integration coverage lives in the full build tests.
|
|
|
|
interface MinimalExtension {
|
|
path: string;
|
|
tools: Map<string, unknown>;
|
|
commands: Map<string, unknown>;
|
|
flags: Map<string, unknown>;
|
|
}
|
|
|
|
function extractExtensionKey(
|
|
ownerPath: string,
|
|
extensionsDir: string,
|
|
): string | undefined {
|
|
const normalizedDir = resolve(extensionsDir);
|
|
const normalizedPath = resolve(ownerPath);
|
|
const prefix = normalizedDir.endsWith(sep)
|
|
? normalizedDir
|
|
: `${normalizedDir}${sep}`;
|
|
if (!normalizedPath.startsWith(prefix)) {
|
|
return undefined;
|
|
}
|
|
const relPath = relative(normalizedDir, normalizedPath);
|
|
const firstSegment = relPath.split(/[\\/]/)[0];
|
|
return firstSegment?.replace(/\.(?:ts|js)$/, "") || undefined;
|
|
}
|
|
|
|
function detectExtensionConflicts(
|
|
extensions: MinimalExtension[],
|
|
bundledExtensionKeys: Set<string>,
|
|
extensionsDir: string,
|
|
): Array<{ path: string; message: string }> {
|
|
const conflicts: Array<{ path: string; message: string }> = [];
|
|
const toolOwners = new Map<string, string>();
|
|
const commandOwners = new Map<string, string>();
|
|
const flagOwners = new Map<string, string>();
|
|
|
|
const isBundled = (ownerPath: string): boolean => {
|
|
const key = extractExtensionKey(ownerPath, extensionsDir);
|
|
return key !== undefined && bundledExtensionKeys.has(key);
|
|
};
|
|
|
|
for (const ext of extensions) {
|
|
for (const toolName of ext.tools.keys()) {
|
|
const existingOwner = toolOwners.get(toolName);
|
|
if (existingOwner && existingOwner !== ext.path) {
|
|
const hint = isBundled(existingOwner)
|
|
? ` (built-in tool supersedes — consider removing ${ext.path})`
|
|
: "";
|
|
conflicts.push({
|
|
path: ext.path,
|
|
message: `Tool "${toolName}" conflicts with ${existingOwner}${hint}`,
|
|
});
|
|
} else {
|
|
toolOwners.set(toolName, ext.path);
|
|
}
|
|
}
|
|
|
|
for (const commandName of ext.commands.keys()) {
|
|
const existingOwner = commandOwners.get(commandName);
|
|
if (existingOwner && existingOwner !== ext.path) {
|
|
const hint = isBundled(existingOwner)
|
|
? ` (built-in command supersedes — consider removing ${ext.path})`
|
|
: "";
|
|
conflicts.push({
|
|
path: ext.path,
|
|
message: `Command "/${commandName}" conflicts with ${existingOwner}${hint}`,
|
|
});
|
|
} else {
|
|
commandOwners.set(commandName, ext.path);
|
|
}
|
|
}
|
|
|
|
for (const flagName of ext.flags.keys()) {
|
|
const existingOwner = flagOwners.get(flagName);
|
|
if (existingOwner && existingOwner !== ext.path) {
|
|
conflicts.push({
|
|
path: ext.path,
|
|
message: `Flag "--${flagName}" conflicts with ${existingOwner}`,
|
|
});
|
|
} else {
|
|
flagOwners.set(flagName, ext.path);
|
|
}
|
|
}
|
|
}
|
|
|
|
return conflicts;
|
|
}
|
|
|
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function makeExtension(
|
|
path: string,
|
|
overrides: { tools?: string[]; commands?: string[]; flags?: string[] } = {},
|
|
): MinimalExtension {
|
|
const tools = new Map<string, unknown>();
|
|
for (const name of overrides.tools ?? []) tools.set(name, {});
|
|
const commands = new Map<string, unknown>();
|
|
for (const name of overrides.commands ?? []) commands.set(name, {});
|
|
const flags = new Map<string, unknown>();
|
|
for (const name of overrides.flags ?? []) flags.set(name, {});
|
|
return { path, tools, commands, flags };
|
|
}
|
|
|
|
// ─── extractExtensionKey ─────────────────────────────────────────────────────
|
|
|
|
describe("extractExtensionKey", () => {
|
|
const extensionsDir = "/home/user/.sf/agent/extensions";
|
|
|
|
it("extracts directory name from a nested extension path", () => {
|
|
assert.equal(
|
|
extractExtensionKey(
|
|
"/home/user/.sf/agent/extensions/mcp-client/index.js",
|
|
extensionsDir,
|
|
),
|
|
"mcp-client",
|
|
);
|
|
});
|
|
|
|
it("strips .ts/.js suffix from flat extension files", () => {
|
|
assert.equal(
|
|
extractExtensionKey(
|
|
"/home/user/.sf/agent/extensions/my-ext.ts",
|
|
extensionsDir,
|
|
),
|
|
"my-ext",
|
|
);
|
|
});
|
|
|
|
it("returns undefined when the path is not under extensionsDir", () => {
|
|
assert.equal(
|
|
extractExtensionKey("/other/path/some-ext/index.js", extensionsDir),
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── detectExtensionConflicts ─────────────────────────────────────────────────
|
|
|
|
describe("detectExtensionConflicts", () => {
|
|
const extensionsDir = "/home/user/.sf/agent/extensions";
|
|
|
|
it("returns no conflicts when extensions have unique tool names", () => {
|
|
const extensions = [
|
|
makeExtension(join(extensionsDir, "ext-a/index.js"), {
|
|
tools: ["tool_a"],
|
|
}),
|
|
makeExtension(join(extensionsDir, "ext-b/index.js"), {
|
|
tools: ["tool_b"],
|
|
}),
|
|
];
|
|
const conflicts = detectExtensionConflicts(
|
|
extensions,
|
|
new Set(["ext-a"]),
|
|
extensionsDir,
|
|
);
|
|
assert.equal(conflicts.length, 0);
|
|
});
|
|
|
|
it("adds supersedes hint when first-registered tool owner is a bundled extension", () => {
|
|
const bundledPath = join(extensionsDir, "mcp-client/index.js");
|
|
const userPath = join(extensionsDir, "mcporter/index.ts");
|
|
|
|
const extensions = [
|
|
makeExtension(bundledPath, { tools: ["mcp_servers"] }),
|
|
makeExtension(userPath, { tools: ["mcp_servers"] }),
|
|
];
|
|
|
|
const conflicts = detectExtensionConflicts(
|
|
extensions,
|
|
new Set(["mcp-client"]),
|
|
extensionsDir,
|
|
);
|
|
|
|
assert.equal(conflicts.length, 1);
|
|
assert.ok(
|
|
conflicts[0].message.includes("supersedes"),
|
|
`Expected "supersedes" in message, got: ${conflicts[0].message}`,
|
|
);
|
|
assert.equal(conflicts[0].path, userPath);
|
|
});
|
|
|
|
it("omits supersedes hint when first-registered tool owner is NOT bundled", () => {
|
|
const userPathA = join(extensionsDir, "mcporter/index.ts");
|
|
const userPathB = join(extensionsDir, "mcporter-v2/index.ts");
|
|
|
|
const extensions = [
|
|
makeExtension(userPathA, { tools: ["mcp_servers"] }),
|
|
makeExtension(userPathB, { tools: ["mcp_servers"] }),
|
|
];
|
|
|
|
const conflicts = detectExtensionConflicts(
|
|
extensions,
|
|
new Set(["mcp-client"]),
|
|
extensionsDir,
|
|
);
|
|
|
|
assert.equal(conflicts.length, 1);
|
|
assert.ok(
|
|
!conflicts[0].message.includes("supersedes"),
|
|
`Expected no "supersedes" in message, got: ${conflicts[0].message}`,
|
|
);
|
|
});
|
|
|
|
it("adds supersedes hint for command conflicts with bundled extensions", () => {
|
|
const bundledPath = join(extensionsDir, "mcp-client/index.js");
|
|
const userPath = join(extensionsDir, "mcporter/index.ts");
|
|
|
|
const extensions = [
|
|
makeExtension(bundledPath, { commands: ["mcp"] }),
|
|
makeExtension(userPath, { commands: ["mcp"] }),
|
|
];
|
|
|
|
const conflicts = detectExtensionConflicts(
|
|
extensions,
|
|
new Set(["mcp-client"]),
|
|
extensionsDir,
|
|
);
|
|
|
|
assert.equal(conflicts.length, 1);
|
|
assert.ok(
|
|
conflicts[0].message.includes("supersedes"),
|
|
`Expected "supersedes" in command conflict, got: ${conflicts[0].message}`,
|
|
);
|
|
});
|
|
|
|
it("works with an empty bundledExtensionKeys set (backwards compat)", () => {
|
|
const pathA = join(extensionsDir, "ext-a/index.js");
|
|
const pathB = join(extensionsDir, "ext-b/index.js");
|
|
|
|
const extensions = [
|
|
makeExtension(pathA, { tools: ["shared_tool"] }),
|
|
makeExtension(pathB, { tools: ["shared_tool"] }),
|
|
];
|
|
|
|
const conflicts = detectExtensionConflicts(
|
|
extensions,
|
|
new Set(),
|
|
extensionsDir,
|
|
);
|
|
|
|
assert.equal(conflicts.length, 1);
|
|
assert.ok(
|
|
!conflicts[0].message.includes("supersedes"),
|
|
`Expected no "supersedes" when bundledKeys empty, got: ${conflicts[0].message}`,
|
|
);
|
|
});
|
|
|
|
it("reproduces issue #2075: bundled extension under /.sf/agent/extensions/ was never identified as built-in", () => {
|
|
// Before the fix, the isBuiltIn check used path heuristics that excluded
|
|
// paths containing /.sf/agent/extensions/, so bundled extensions placed
|
|
// there by initResources() could never be recognized as built-in.
|
|
const bundledPath = "/home/user/.sf/agent/extensions/mcp-client/index.js";
|
|
const userPath = "/home/user/.sf/agent/extensions/mcporter/index.ts";
|
|
|
|
const extensions = [
|
|
makeExtension(bundledPath, {
|
|
tools: ["mcp_servers", "mcp_discover", "mcp_call"],
|
|
}),
|
|
makeExtension(userPath, {
|
|
tools: ["mcp_servers", "mcp_discover", "mcp_call"],
|
|
}),
|
|
];
|
|
|
|
const bundledKeys = new Set(["mcp-client"]);
|
|
const conflicts = detectExtensionConflicts(
|
|
extensions,
|
|
bundledKeys,
|
|
"/home/user/.sf/agent/extensions",
|
|
);
|
|
|
|
// All three conflicting tools should include the supersedes hint
|
|
assert.equal(conflicts.length, 3);
|
|
for (const conflict of conflicts) {
|
|
assert.ok(
|
|
conflict.message.includes("supersedes"),
|
|
`Conflict for tool should include "supersedes" hint, got: ${conflict.message}`,
|
|
);
|
|
}
|
|
});
|
|
});
|