From d92c5f827dfb98eed99847d7a8649e6316fba692 Mon Sep 17 00:00:00 2001 From: GRayHook Date: Wed, 17 Jun 2026 13:11:32 +0700 Subject: [PATCH] =?UTF-8?q?Phase=2012:=20skill=20name=20collision=20warn?= =?UTF-8?q?=20=E2=80=94=20SPEC=20=C2=A711.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duplicate names in resourceLoader resolve to the first skill with a one-time UI warning during re-inject expansion. Co-authored-by: Cursor --- src/reinject.ts | 33 ++++++++++++++++++++++++++++++--- test/reinject-collision.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 test/reinject-collision.test.ts diff --git a/src/reinject.ts b/src/reinject.ts index 41c3bb3..dd4f9c6 100644 --- a/src/reinject.ts +++ b/src/reinject.ts @@ -18,11 +18,38 @@ import type { ExtensionState } from "./state.js"; export const DEFERRED_REINJECT_CUSTOM_TYPE = "skill-reinject:inject"; -function notifySkippedSkill(ctx: ExtensionContext | undefined, skillName: string, reason: string): void { +function notifyWarning(ctx: ExtensionContext | undefined, message: string): void { if (!ctx?.hasUI) { return; } - ctx.ui.notify(`skill-reinject: skipped "${skillName}" — ${reason}`, "warning"); + ctx.ui.notify(message, "warning"); +} + +function notifySkippedSkill(ctx: ExtensionContext | undefined, skillName: string, reason: string): void { + notifyWarning(ctx, `skill-reinject: skipped "${skillName}" — ${reason}`); +} + +/** First registered skill per name; warn on resourceLoader collisions (SPEC §11). */ +export function registeredSkillsByName>( + registeredSkills: readonly T[], + ctx?: ExtensionContext, +): Map { + const byName = new Map(); + const warned = new Set(); + for (const skill of registeredSkills) { + if (byName.has(skill.name)) { + if (!warned.has(skill.name)) { + warned.add(skill.name); + notifyWarning( + ctx, + `skill-reinject: duplicate skill name "${skill.name}" — using first from resourceLoader`, + ); + } + continue; + } + byName.set(skill.name, skill); + } + return byName; } /** Names still registered in resourceLoader (SPEC §5.2). */ @@ -77,7 +104,7 @@ export function buildReinjectBlocks( registeredSkills: readonly Pick[], ctx?: ExtensionContext, ): string[] { - const registeredByName = new Map(registeredSkills.map((skill) => [skill.name, skill])); + const registeredByName = registeredSkillsByName(registeredSkills, ctx); const blocks: string[] = []; for (const name of skillNames) { const tracked = state.skills.find((skill) => skill.name === name); diff --git a/test/reinject-collision.test.ts b/test/reinject-collision.test.ts new file mode 100644 index 0000000..a323d44 --- /dev/null +++ b/test/reinject-collision.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; +import { registeredSkillsByName } from "../src/reinject"; + +describe("registeredSkillsByName", () => { + it("keeps the first skill when names collide", () => { + const first = { name: "alpha", filePath: "/a/SKILL.md", baseDir: "/a" }; + const second = { name: "alpha", filePath: "/b/SKILL.md", baseDir: "/b" }; + + const byName = registeredSkillsByName([first, second]); + + expect(byName.get("alpha")).toBe(first); + }); + + it("warns once per duplicate name", () => { + const notify = vi.fn(); + const ctx = { hasUI: true, ui: { notify } } as never; + const skills = [ + { name: "alpha", filePath: "/a/SKILL.md", baseDir: "/a" }, + { name: "alpha", filePath: "/b/SKILL.md", baseDir: "/b" }, + { name: "alpha", filePath: "/c/SKILL.md", baseDir: "/c" }, + ]; + + registeredSkillsByName(skills, ctx); + + expect(notify).toHaveBeenCalledTimes(1); + expect(notify).toHaveBeenCalledWith( + 'skill-reinject: duplicate skill name "alpha" — using first from resourceLoader', + "warning", + ); + }); +});