refactor(coding-agent): unify SkillWarning and ResourceDiagnostic

- Create shared diagnostics.ts with ResourceCollision and ResourceDiagnostic types
- Update loadSkills() to return diagnostics instead of warnings
- Remove SkillWarning type and skillWarningsToDiagnostics() conversion
- Update skills.test.ts to use diagnostics
This commit is contained in:
Mario Zechner 2026-01-24 00:45:01 +01:00
parent 6d0d434d8c
commit 4719929f6a
5 changed files with 95 additions and 111 deletions

View file

@ -1,6 +1,7 @@
import { homedir } from "os";
import { join, resolve } from "path";
import { describe, expect, it } from "vitest";
import type { ResourceDiagnostic } from "../src/core/diagnostics.js";
import { formatSkillsForPrompt, loadSkills, loadSkillsFromDir, type Skill } from "../src/core/skills.js";
const fixturesDir = resolve(__dirname, "fixtures/skills");
@ -9,7 +10,7 @@ const collisionFixturesDir = resolve(__dirname, "fixtures/skills-collision");
describe("skills", () => {
describe("loadSkillsFromDir", () => {
it("should load a valid skill", () => {
const { skills, warnings } = loadSkillsFromDir({
const { skills, diagnostics } = loadSkillsFromDir({
dir: join(fixturesDir, "valid-skill"),
source: "test",
});
@ -18,95 +19,101 @@ describe("skills", () => {
expect(skills[0].name).toBe("valid-skill");
expect(skills[0].description).toBe("A valid skill for testing purposes.");
expect(skills[0].source).toBe("test");
expect(warnings).toHaveLength(0);
expect(diagnostics).toHaveLength(0);
});
it("should warn when name doesn't match parent directory", () => {
const { skills, warnings } = loadSkillsFromDir({
const { skills, diagnostics } = loadSkillsFromDir({
dir: join(fixturesDir, "name-mismatch"),
source: "test",
});
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe("different-name");
expect(warnings.some((w) => w.message.includes("does not match parent directory"))).toBe(true);
expect(
diagnostics.some((d: ResourceDiagnostic) => d.message.includes("does not match parent directory")),
).toBe(true);
});
it("should warn when name contains invalid characters", () => {
const { skills, warnings } = loadSkillsFromDir({
const { skills, diagnostics } = loadSkillsFromDir({
dir: join(fixturesDir, "invalid-name-chars"),
source: "test",
});
expect(skills).toHaveLength(1);
expect(warnings.some((w) => w.message.includes("invalid characters"))).toBe(true);
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("invalid characters"))).toBe(true);
});
it("should warn when name exceeds 64 characters", () => {
const { skills, warnings } = loadSkillsFromDir({
const { skills, diagnostics } = loadSkillsFromDir({
dir: join(fixturesDir, "long-name"),
source: "test",
});
expect(skills).toHaveLength(1);
expect(warnings.some((w) => w.message.includes("exceeds 64 characters"))).toBe(true);
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("exceeds 64 characters"))).toBe(true);
});
it("should warn and skip skill when description is missing", () => {
const { skills, warnings } = loadSkillsFromDir({
const { skills, diagnostics } = loadSkillsFromDir({
dir: join(fixturesDir, "missing-description"),
source: "test",
});
expect(skills).toHaveLength(0);
expect(warnings.some((w) => w.message.includes("description is required"))).toBe(true);
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("description is required"))).toBe(true);
});
it("should warn when unknown frontmatter fields are present", () => {
const { skills, warnings } = loadSkillsFromDir({
const { skills, diagnostics } = loadSkillsFromDir({
dir: join(fixturesDir, "unknown-field"),
source: "test",
});
expect(skills).toHaveLength(1);
expect(warnings.some((w) => w.message.includes('unknown frontmatter field "author"'))).toBe(true);
expect(warnings.some((w) => w.message.includes('unknown frontmatter field "version"'))).toBe(true);
expect(
diagnostics.some((d: ResourceDiagnostic) => d.message.includes('unknown frontmatter field "author"')),
).toBe(true);
expect(
diagnostics.some((d: ResourceDiagnostic) => d.message.includes('unknown frontmatter field "version"')),
).toBe(true);
});
it("should load nested skills recursively", () => {
const { skills, warnings } = loadSkillsFromDir({
const { skills, diagnostics } = loadSkillsFromDir({
dir: join(fixturesDir, "nested"),
source: "test",
});
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe("child-skill");
expect(warnings).toHaveLength(0);
expect(diagnostics).toHaveLength(0);
});
it("should skip files without frontmatter", () => {
const { skills, warnings } = loadSkillsFromDir({
const { skills, diagnostics } = loadSkillsFromDir({
dir: join(fixturesDir, "no-frontmatter"),
source: "test",
});
// no-frontmatter has no description, so it should be skipped
expect(skills).toHaveLength(0);
expect(warnings.some((w) => w.message.includes("description is required"))).toBe(true);
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("description is required"))).toBe(true);
});
it("should warn and skip skill when YAML frontmatter is invalid", () => {
const { skills, warnings } = loadSkillsFromDir({
const { skills, diagnostics } = loadSkillsFromDir({
dir: join(fixturesDir, "invalid-yaml"),
source: "test",
});
expect(skills).toHaveLength(0);
expect(warnings.some((w) => w.message.includes("at line"))).toBe(true);
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("at line"))).toBe(true);
});
it("should preserve multiline descriptions from YAML", () => {
const { skills, warnings } = loadSkillsFromDir({
const { skills, diagnostics } = loadSkillsFromDir({
dir: join(fixturesDir, "multiline-description"),
source: "test",
});
@ -114,17 +121,17 @@ describe("skills", () => {
expect(skills).toHaveLength(1);
expect(skills[0].description).toContain("\n");
expect(skills[0].description).toContain("This is a multiline description.");
expect(warnings).toHaveLength(0);
expect(diagnostics).toHaveLength(0);
});
it("should warn when name contains consecutive hyphens", () => {
const { skills, warnings } = loadSkillsFromDir({
const { skills, diagnostics } = loadSkillsFromDir({
dir: join(fixturesDir, "consecutive-hyphens"),
source: "test",
});
expect(skills).toHaveLength(1);
expect(warnings.some((w) => w.message.includes("consecutive hyphens"))).toBe(true);
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("consecutive hyphens"))).toBe(true);
});
it("should load all skills from fixture directory", () => {
@ -140,13 +147,13 @@ describe("skills", () => {
});
it("should return empty for non-existent directory", () => {
const { skills, warnings } = loadSkillsFromDir({
const { skills, diagnostics } = loadSkillsFromDir({
dir: "/non/existent/path",
source: "test",
});
expect(skills).toHaveLength(0);
expect(warnings).toHaveLength(0);
expect(diagnostics).toHaveLength(0);
});
it("should use parent directory name when name not in frontmatter", () => {
@ -258,24 +265,24 @@ describe("skills", () => {
const emptyCwd = resolve(__dirname, "fixtures/empty-cwd");
it("should load from explicit skillPaths", () => {
const { skills, warnings } = loadSkills({
const { skills, diagnostics } = loadSkills({
agentDir: emptyAgentDir,
cwd: emptyCwd,
skillPaths: [join(fixturesDir, "valid-skill")],
});
expect(skills).toHaveLength(1);
expect(skills[0].source).toBe("custom");
expect(warnings).toHaveLength(0);
expect(diagnostics).toHaveLength(0);
});
it("should warn when skill path does not exist", () => {
const { skills, warnings } = loadSkills({
const { skills, diagnostics } = loadSkills({
agentDir: emptyAgentDir,
cwd: emptyCwd,
skillPaths: ["/non/existent/path"],
});
expect(skills).toHaveLength(0);
expect(warnings.some((w) => w.message.includes("does not exist"))).toBe(true);
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("does not exist"))).toBe(true);
});
it("should expand ~ in skillPaths", () => {