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

@ -0,0 +1,15 @@
export interface ResourceCollision {
resourceType: "extension" | "skill" | "prompt" | "theme";
name: string; // skill name, command/tool/flag name, prompt name, theme name
winnerPath: string;
loserPath: string;
winnerSource?: string; // e.g., "npm:foo", "git:...", "local"
loserSource?: string;
}
export interface ResourceDiagnostic {
type: "warning" | "error" | "collision";
message: string;
path?: string;
collision?: ResourceCollision;
}

View file

@ -4,6 +4,10 @@ import { join, resolve } from "node:path";
import chalk from "chalk";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.js";
import type { ResourceDiagnostic } from "./diagnostics.js";
export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.js";
import { createEventBus, type EventBus } from "./event-bus.js";
import {
createExtensionRuntime,
@ -16,25 +20,9 @@ import { DefaultPackageManager, type PathMetadata } from "./package-manager.js";
import type { PromptTemplate } from "./prompt-templates.js";
import { loadPromptTemplates } from "./prompt-templates.js";
import { SettingsManager } from "./settings-manager.js";
import type { Skill, SkillWarning } from "./skills.js";
import type { Skill } from "./skills.js";
import { loadSkills } from "./skills.js";
export interface ResourceCollision {
resourceType: "extension" | "skill" | "prompt" | "theme";
name: string; // skill name, command/tool/flag name, prompt name, theme name
winnerPath: string;
loserPath: string;
winnerSource?: string; // e.g., "npm:foo", "git:...", "local"
loserSource?: string;
}
export interface ResourceDiagnostic {
type: "warning" | "error" | "collision";
message: string;
path?: string;
collision?: ResourceCollision;
}
export interface ResourceLoader {
getExtensions(): LoadExtensionsResult;
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
@ -332,12 +320,11 @@ export class DefaultResourceLoader implements ResourceLoader {
if (this.noSkills && skillPaths.length === 0) {
skillsResult = { skills: [], diagnostics: [] };
} else {
const result = loadSkills({
skillsResult = loadSkills({
cwd: this.cwd,
agentDir: this.agentDir,
skillPaths,
});
skillsResult = { skills: result.skills, diagnostics: this.skillWarningsToDiagnostics(result.warnings) };
}
const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult;
this.skills = resolvedSkills.skills;
@ -520,30 +507,6 @@ export class DefaultResourceLoader implements ResourceLoader {
return { extensions, errors };
}
private skillWarningsToDiagnostics(warnings: SkillWarning[]): ResourceDiagnostic[] {
return warnings.map((w) => {
// If it's a name collision, create proper collision structure
if (w.collisionName && w.collisionWinner) {
return {
type: "collision" as const,
message: w.message,
path: w.skillPath,
collision: {
resourceType: "skill" as const,
name: w.collisionName,
winnerPath: w.collisionWinner,
loserPath: w.skillPath,
},
};
}
return {
type: "warning" as const,
message: w.message,
path: w.skillPath,
};
});
}
private dedupePrompts(prompts: PromptTemplate[]): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } {
const seen = new Map<string, PromptTemplate>();
const diagnostics: ResourceDiagnostic[] = [];

View file

@ -3,6 +3,7 @@ import { homedir } from "os";
import { basename, dirname, isAbsolute, join, resolve } from "path";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js";
import type { ResourceDiagnostic } from "./diagnostics.js";
/**
* Standard frontmatter fields per Agent Skills spec.
@ -37,18 +38,9 @@ export interface Skill {
source: string;
}
export interface SkillWarning {
skillPath: string;
message: string;
/** For name collisions, the name that collided */
collisionName?: string;
/** For name collisions, the path of the skill that was loaded (winner) */
collisionWinner?: string;
}
export interface LoadSkillsResult {
skills: Skill[];
warnings: SkillWarning[];
diagnostics: ResourceDiagnostic[];
}
/**
@ -130,10 +122,10 @@ export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkills
function loadSkillsFromDirInternal(dir: string, source: string, includeRootFiles: boolean): LoadSkillsResult {
const skills: Skill[] = [];
const warnings: SkillWarning[] = [];
const diagnostics: ResourceDiagnostic[] = [];
if (!existsSync(dir)) {
return { skills, warnings };
return { skills, diagnostics };
}
try {
@ -168,7 +160,7 @@ function loadSkillsFromDirInternal(dir: string, source: string, includeRootFiles
if (isDirectory) {
const subResult = loadSkillsFromDirInternal(fullPath, source, false);
skills.push(...subResult.skills);
warnings.push(...subResult.warnings);
diagnostics.push(...subResult.diagnostics);
continue;
}
@ -186,15 +178,18 @@ function loadSkillsFromDirInternal(dir: string, source: string, includeRootFiles
if (result.skill) {
skills.push(result.skill);
}
warnings.push(...result.warnings);
diagnostics.push(...result.diagnostics);
}
} catch {}
return { skills, warnings };
return { skills, diagnostics };
}
function loadSkillFromFile(filePath: string, source: string): { skill: Skill | null; warnings: SkillWarning[] } {
const warnings: SkillWarning[] = [];
function loadSkillFromFile(
filePath: string,
source: string,
): { skill: Skill | null; diagnostics: ResourceDiagnostic[] } {
const diagnostics: ResourceDiagnostic[] = [];
try {
const rawContent = readFileSync(filePath, "utf-8");
@ -206,13 +201,13 @@ function loadSkillFromFile(filePath: string, source: string): { skill: Skill | n
// Validate frontmatter fields
const fieldErrors = validateFrontmatterFields(allKeys);
for (const error of fieldErrors) {
warnings.push({ skillPath: filePath, message: error });
diagnostics.push({ type: "warning", message: error, path: filePath });
}
// Validate description
const descErrors = validateDescription(frontmatter.description);
for (const error of descErrors) {
warnings.push({ skillPath: filePath, message: error });
diagnostics.push({ type: "warning", message: error, path: filePath });
}
// Use name from frontmatter, or fall back to parent directory name
@ -221,12 +216,12 @@ function loadSkillFromFile(filePath: string, source: string): { skill: Skill | n
// Validate name
const nameErrors = validateName(name, parentDirName);
for (const error of nameErrors) {
warnings.push({ skillPath: filePath, message: error });
diagnostics.push({ type: "warning", message: error, path: filePath });
}
// Still load the skill even with warnings (unless description is completely missing)
if (!frontmatter.description || frontmatter.description.trim() === "") {
return { skill: null, warnings };
return { skill: null, diagnostics };
}
return {
@ -237,12 +232,12 @@ function loadSkillFromFile(filePath: string, source: string): { skill: Skill | n
baseDir: skillDir,
source,
},
warnings,
diagnostics,
};
} catch (error) {
const message = error instanceof Error ? error.message : "failed to parse skill file";
warnings.push({ skillPath: filePath, message });
return { skill: null, warnings };
diagnostics.push({ type: "warning", message, path: filePath });
return { skill: null, diagnostics };
}
}
@ -309,7 +304,7 @@ function resolveSkillPath(p: string, cwd: string): string {
/**
* Load skills from all configured locations.
* Returns skills and any validation warnings.
* Returns skills and any validation diagnostics.
*/
export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
const { cwd = process.cwd(), agentDir, skillPaths = [] } = options;
@ -319,11 +314,11 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
const skillMap = new Map<string, Skill>();
const realPathSet = new Set<string>();
const allWarnings: SkillWarning[] = [];
const collisionWarnings: SkillWarning[] = [];
const allDiagnostics: ResourceDiagnostic[] = [];
const collisionDiagnostics: ResourceDiagnostic[] = [];
function addSkills(result: LoadSkillsResult) {
allWarnings.push(...result.warnings);
allDiagnostics.push(...result.diagnostics);
for (const skill of result.skills) {
// Resolve symlinks to detect duplicate files
let realPath: string;
@ -340,11 +335,16 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
const existing = skillMap.get(skill.name);
if (existing) {
collisionWarnings.push({
skillPath: skill.filePath,
collisionDiagnostics.push({
type: "collision",
message: `name "${skill.name}" collision`,
collisionName: skill.name,
collisionWinner: existing.filePath,
path: skill.filePath,
collision: {
resourceType: "skill",
name: skill.name,
winnerPath: existing.filePath,
loserPath: skill.filePath,
},
});
} else {
skillMap.set(skill.name, skill);
@ -359,7 +359,7 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
for (const rawPath of skillPaths) {
const resolvedPath = resolveSkillPath(rawPath, cwd);
if (!existsSync(resolvedPath)) {
allWarnings.push({ skillPath: resolvedPath, message: "skill path does not exist" });
allDiagnostics.push({ type: "warning", message: "skill path does not exist", path: resolvedPath });
continue;
}
@ -370,21 +370,21 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
} else if (stats.isFile() && resolvedPath.endsWith(".md")) {
const result = loadSkillFromFile(resolvedPath, "custom");
if (result.skill) {
addSkills({ skills: [result.skill], warnings: result.warnings });
addSkills({ skills: [result.skill], diagnostics: result.diagnostics });
} else {
allWarnings.push(...result.warnings);
allDiagnostics.push(...result.diagnostics);
}
} else {
allWarnings.push({ skillPath: resolvedPath, message: "skill path is not a markdown file" });
allDiagnostics.push({ type: "warning", message: "skill path is not a markdown file", path: resolvedPath });
}
} catch (error) {
const message = error instanceof Error ? error.message : "failed to read skill path";
allWarnings.push({ skillPath: resolvedPath, message });
allDiagnostics.push({ type: "warning", message, path: resolvedPath });
}
}
return {
skills: Array.from(skillMap.values()),
warnings: [...allWarnings, ...collisionWarnings],
diagnostics: [...allDiagnostics, ...collisionDiagnostics],
};
}

View file

@ -187,7 +187,6 @@ export {
loadSkillsFromDir,
type Skill,
type SkillFrontmatter,
type SkillWarning,
} from "./core/skills.js";
// Tools
export {