mirror of
https://github.com/dotnet/skills.git
synced 2026-09-20 09:49:54 +08:00
292a4bcfa3
* Add validator options * Address PR review: validate options and add tests - Reject --allow-repo-traversal with --plugin mode (plugins must be portable) - Validate --max-declaration-lines and --max-agent-lines are positive integers - Add unit tests for MaxDeclarationLines override in SkillProfiler - Add unit tests for AllowRepoTraversal suppression and depth check preservation - Add unit tests for MaxAgentLines override and fallback to MaxDeclarationLines * Remove body line count checks and --max-declaration-lines/--max-agent-lines options Line count checks are redundant with BPE token-based complexity classification, which already provides better guidance. Keep --allow-repo-traversal only. * Address review: remove unused CheckOptions from AgentProfiler, fix traversal test - Remove unused CheckOptions parameter from AgentProfiler.AnalyzeAgent and RunAgentsCheckCore since line checks were removed - Fix AllowRepoTraversalSuppressesParentTraversalError test to use a non-deep path (../SKILL.md) and assert no file-ref errors at all
495 lines
18 KiB
C#
495 lines
18 KiB
C#
using System.Text.Json;
|
|
using SkillValidator.Check;
|
|
using SkillValidator.Shared;
|
|
|
|
namespace SkillValidator.Tests;
|
|
|
|
public class AgentProfilerTests
|
|
{
|
|
private static AgentInfo MakeAgent(
|
|
string content,
|
|
string name = "test-agent",
|
|
string description = "Test agent description",
|
|
string fileName = "test-agent.agent.md")
|
|
{
|
|
return new AgentInfo(name, description, $"/tmp/agents/{fileName}", content, fileName);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidAgentProducesNoErrors()
|
|
{
|
|
var content = "---\nname: test-agent\ndescription: A test agent.\n---\n# Test Agent\n\nDo the thing.\n";
|
|
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content, "test-agent", "A test agent."));
|
|
Assert.Empty(profile.Errors);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingFrontmatterErrors()
|
|
{
|
|
var content = "# Test Agent\n\nNo frontmatter here.\n";
|
|
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content));
|
|
Assert.Contains(profile.Errors, e => e.Contains("frontmatter"));
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingFrontmatterUsesFilenameAsProfileName()
|
|
{
|
|
var content = "# Test Agent\n\nNo frontmatter here.\n";
|
|
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content, name: "", fileName: "my-agent.agent.md"));
|
|
Assert.Equal("my-agent.agent.md", profile.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingNameErrors()
|
|
{
|
|
var content = "---\ndescription: A test agent.\n---\n# Test\n";
|
|
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content, name: "", description: "A test agent."));
|
|
Assert.Contains(profile.Errors, e => e.Contains("name"));
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingDescriptionErrors()
|
|
{
|
|
var content = "---\nname: test-agent\n---\n# Test\n";
|
|
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content, description: ""));
|
|
Assert.Contains(profile.Errors, e => e.Contains("description"));
|
|
}
|
|
|
|
[Fact]
|
|
public void DescriptionOverLimitErrors()
|
|
{
|
|
var desc = new string('a', 1025);
|
|
var content = $"---\nname: test-agent\ndescription: {desc}\n---\n# Test\n";
|
|
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content, description: desc));
|
|
Assert.Contains(profile.Errors, e => e.Contains("maximum"));
|
|
}
|
|
|
|
[Fact]
|
|
public void DescriptionAtLimitNoError()
|
|
{
|
|
var desc = new string('a', 1024);
|
|
var content = $"---\nname: test-agent\ndescription: {desc}\n---\n# Test\n";
|
|
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content, description: desc));
|
|
Assert.DoesNotContain(profile.Errors, e => e.Contains("maximum"));
|
|
}
|
|
|
|
[Fact]
|
|
public void NameNotMatchingFilenameErrors()
|
|
{
|
|
var content = "---\nname: my-agent\ndescription: test\n---\n# Test\n";
|
|
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content, name: "my-agent", fileName: "different-agent.agent.md"));
|
|
Assert.Contains(profile.Errors, e => e.Contains("does not match filename"));
|
|
}
|
|
|
|
[Fact]
|
|
public void NameMatchingFilenameNoError()
|
|
{
|
|
var content = "---\nname: my-agent\ndescription: test\n---\n# Test\n";
|
|
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content, name: "my-agent", fileName: "my-agent.agent.md"));
|
|
Assert.DoesNotContain(profile.Errors, e => e.Contains("does not match filename"));
|
|
}
|
|
|
|
[Fact]
|
|
public void NameWithUppercaseErrors()
|
|
{
|
|
var content = "---\nname: My-Agent\ndescription: test\n---\n# Test\n";
|
|
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content, name: "My-Agent", fileName: "My-Agent.agent.md"));
|
|
Assert.Contains(profile.Errors, e => e.Contains("invalid characters"));
|
|
}
|
|
|
|
[Fact]
|
|
public void NameTooLongErrors()
|
|
{
|
|
var longName = new string('a', 65);
|
|
var content = $"---\nname: {longName}\ndescription: test\n---\n# Test\n";
|
|
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content, name: longName, fileName: $"{longName}.agent.md"));
|
|
Assert.Contains(profile.Errors, e => e.Contains("maximum is 64"));
|
|
}
|
|
|
|
[Fact]
|
|
public void NameStartingWithHyphenErrors()
|
|
{
|
|
var content = "---\nname: -my-agent\ndescription: test\n---\n# Test\n";
|
|
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content, name: "-my-agent", fileName: "-my-agent.agent.md"));
|
|
Assert.Contains(profile.Errors, e => e.Contains("starts or ends with a hyphen"));
|
|
}
|
|
|
|
[Fact]
|
|
public void NameEndingWithHyphenErrors()
|
|
{
|
|
var content = "---\nname: my-agent-\ndescription: test\n---\n# Test\n";
|
|
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content, name: "my-agent-", fileName: "my-agent-.agent.md"));
|
|
Assert.Contains(profile.Errors, e => e.Contains("starts or ends with a hyphen"));
|
|
}
|
|
|
|
[Fact]
|
|
public void NameWithConsecutiveHyphensErrors()
|
|
{
|
|
var content = "---\nname: my--agent\ndescription: test\n---\n# Test\n";
|
|
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content, name: "my--agent", fileName: "my--agent.agent.md"));
|
|
Assert.Contains(profile.Errors, e => e.Contains("consecutive hyphens"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ErrorMessagesSayAgentNotSkill()
|
|
{
|
|
var content = "---\nname: My-Agent\ndescription: test\n---\n# Test\n";
|
|
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content, name: "My-Agent", fileName: "My-Agent.agent.md"));
|
|
Assert.Contains(profile.Errors, e => e.StartsWith("Agent name"));
|
|
Assert.DoesNotContain(profile.Errors, e => e.StartsWith("Skill name"));
|
|
}
|
|
}
|
|
|
|
public class PluginProfilerTests
|
|
{
|
|
[Fact]
|
|
public void ValidPluginProducesNoErrors()
|
|
{
|
|
var pluginDir = Path.Combine(Path.GetTempPath(), "plugin-test-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
Directory.CreateDirectory(pluginDir);
|
|
Directory.CreateDirectory(Path.Combine(pluginDir, "skills"));
|
|
Directory.CreateDirectory(Path.Combine(pluginDir, "agents"));
|
|
File.WriteAllText(Path.Combine(pluginDir, "agents", "test.agent.md"), "---\nname: test\ndescription: test\n---\n# Test\n");
|
|
var dirName = Path.GetFileName(pluginDir);
|
|
|
|
var plugin = new PluginInfo(dirName, "1.0.0", "A test plugin.", ["./skills/"], ["./agents/test.agent.md"], pluginDir, dirName);
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Empty(result.Errors);
|
|
Assert.Empty(result.Warnings);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(pluginDir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingNameErrors()
|
|
{
|
|
var plugin = new PluginInfo("", "1.0.0", "desc", ["./skills/"], [], "/tmp/test", "test");
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Contains(result.Errors, e => e.Contains("name"));
|
|
}
|
|
|
|
[Fact]
|
|
public void NameNotMatchingDirectoryErrors()
|
|
{
|
|
var plugin = new PluginInfo("wrong-name", "1.0.0", "desc", ["./skills/"], [], "/tmp/my-plugin", "my-plugin");
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Contains(result.Errors, e => e.Contains("does not match directory"));
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingVersionErrors()
|
|
{
|
|
var plugin = new PluginInfo("test", null, "desc", ["./skills/"], [], "/tmp/test", "test");
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Contains(result.Errors, e => e.Contains("version"));
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingDescriptionErrors()
|
|
{
|
|
var plugin = new PluginInfo("test", "1.0.0", null, ["./skills/"], [], "/tmp/test", "test");
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Contains(result.Errors, e => e.Contains("description"));
|
|
}
|
|
|
|
[Fact]
|
|
public void DescriptionOverLimitErrors()
|
|
{
|
|
var desc = new string('a', 1025);
|
|
var plugin = new PluginInfo("test", "1.0.0", desc, ["./skills/"], [], "/tmp/test", "test");
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Contains(result.Errors, e => e.Contains("maximum"));
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingSkillsPathErrors()
|
|
{
|
|
var plugin = new PluginInfo("test", "1.0.0", "desc", [], [], "/tmp/test", "test");
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Contains(result.Errors, e => e.Contains("skills"));
|
|
}
|
|
|
|
[Fact]
|
|
public void NonexistentSkillsPathErrors()
|
|
{
|
|
var plugin = new PluginInfo("test", "1.0.0", "desc", ["./nonexistent/"], [], "/tmp/test", "test");
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Contains(result.Errors, e => e.Contains("does not exist"));
|
|
}
|
|
|
|
[Fact]
|
|
public void NonexistentAgentsPathErrors()
|
|
{
|
|
var pluginDir = Path.Combine(Path.GetTempPath(), "plugin-test-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
Directory.CreateDirectory(pluginDir);
|
|
Directory.CreateDirectory(Path.Combine(pluginDir, "skills"));
|
|
var dirName = Path.GetFileName(pluginDir);
|
|
|
|
var plugin = new PluginInfo(dirName, "1.0.0", "desc", ["./skills/"], ["./nonexistent.agent.md"], pluginDir, dirName);
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Contains(result.Errors, e => e.Contains("does not exist"));
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(pluginDir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void NonexistentAgentFilePathErrors()
|
|
{
|
|
var pluginDir = Path.Combine(Path.GetTempPath(), "plugin-test-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
Directory.CreateDirectory(pluginDir);
|
|
Directory.CreateDirectory(Path.Combine(pluginDir, "skills"));
|
|
var dirName = Path.GetFileName(pluginDir);
|
|
|
|
var plugin = new PluginInfo(dirName, "1.0.0", "desc", ["./skills/"], ["./agents/missing.agent.md"], pluginDir, dirName);
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Contains(result.Errors, e => e.Contains("does not exist"));
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(pluginDir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AgentDirectoryPathErrors()
|
|
{
|
|
var pluginDir = Path.Combine(Path.GetTempPath(), "plugin-test-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
Directory.CreateDirectory(pluginDir);
|
|
Directory.CreateDirectory(Path.Combine(pluginDir, "skills"));
|
|
Directory.CreateDirectory(Path.Combine(pluginDir, "agents"));
|
|
File.WriteAllText(Path.Combine(pluginDir, "agents", "test.agent.md"), "---\nname: test\ndescription: test\n---\n# Test\n");
|
|
var dirName = Path.GetFileName(pluginDir);
|
|
|
|
var plugin = new PluginInfo(dirName, "1.0.0", "desc", ["./skills/"], ["./agents/"], pluginDir, dirName);
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Contains(result.Errors, e => e.Contains("is a directory") && e.Contains("explicit file paths"));
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(pluginDir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidAgentPathsArrayProducesNoWarnings()
|
|
{
|
|
var pluginDir = Path.Combine(Path.GetTempPath(), "plugin-test-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
Directory.CreateDirectory(pluginDir);
|
|
Directory.CreateDirectory(Path.Combine(pluginDir, "skills"));
|
|
Directory.CreateDirectory(Path.Combine(pluginDir, "agents"));
|
|
File.WriteAllText(Path.Combine(pluginDir, "agents", "test.agent.md"), "---\nname: test\ndescription: test\n---\n# Test\n");
|
|
var dirName = Path.GetFileName(pluginDir);
|
|
|
|
var plugin = new PluginInfo(dirName, "1.0.0", "A test plugin.", ["./skills/"], ["./agents/test.agent.md"], pluginDir, dirName);
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Empty(result.Errors);
|
|
Assert.Empty(result.Warnings);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(pluginDir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidSkillPathsArrayProducesNoErrors()
|
|
{
|
|
var pluginDir = Path.Combine(Path.GetTempPath(), "plugin-test-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
Directory.CreateDirectory(pluginDir);
|
|
Directory.CreateDirectory(Path.Combine(pluginDir, "skills"));
|
|
var dirName = Path.GetFileName(pluginDir);
|
|
|
|
var plugin = new PluginInfo(dirName, "1.0.0", "A test plugin.", ["./skills/"], [], pluginDir, dirName);
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Empty(result.Errors);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(pluginDir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void NonexistentSkillPathInArrayErrors()
|
|
{
|
|
var pluginDir = Path.Combine(Path.GetTempPath(), "plugin-test-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
Directory.CreateDirectory(pluginDir);
|
|
var dirName = Path.GetFileName(pluginDir);
|
|
|
|
var plugin = new PluginInfo(dirName, "1.0.0", "desc", ["./nonexistent/"], [], pluginDir, dirName);
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Contains(result.Errors, e => e.Contains("does not exist"));
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(pluginDir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void NameFormatErrors()
|
|
{
|
|
var plugin = new PluginInfo("My_Plugin", "1.0.0", "desc", ["./skills/"], [], "/tmp/My_Plugin", "My_Plugin");
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Contains(result.Errors, e => e.Contains("invalid characters"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ErrorMessagesSayPluginNotSkill()
|
|
{
|
|
var plugin = new PluginInfo("My_Plugin", "1.0.0", "desc", ["./skills/"], [], "/tmp/My_Plugin", "My_Plugin");
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Contains(result.Errors, e => e.StartsWith("Plugin name"));
|
|
Assert.DoesNotContain(result.Errors, e => e.StartsWith("Skill name"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ParsePluginJsonReturnsNullForMissingFile()
|
|
{
|
|
var result = PluginDiscovery.ParsePluginJson("/nonexistent/plugin.json");
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParsePluginJsonParsesValidFile()
|
|
{
|
|
var dir = Path.Combine(Path.GetTempPath(), "parse-test-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
Directory.CreateDirectory(dir);
|
|
var jsonPath = Path.Combine(dir, "plugin.json");
|
|
File.WriteAllText(jsonPath, """{"name":"my-plugin","version":"0.1.0","description":"A plugin.","skills":["./skills/"],"agents":["./agents/"]}""");
|
|
|
|
var plugin = PluginDiscovery.ParsePluginJson(jsonPath);
|
|
Assert.NotNull(plugin);
|
|
Assert.Equal("my-plugin", plugin.Name);
|
|
Assert.Equal("0.1.0", plugin.Version);
|
|
Assert.Equal("A plugin.", plugin.Description);
|
|
Assert.Single(plugin.SkillPaths);
|
|
Assert.Equal("./skills/", plugin.SkillPaths[0]);
|
|
Assert.Single(plugin.AgentPaths);
|
|
Assert.Equal("./agents/", plugin.AgentPaths[0]);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(dir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ParsePluginJsonNormalizesStringToArray()
|
|
{
|
|
var dir = Path.Combine(Path.GetTempPath(), "parse-test-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
Directory.CreateDirectory(dir);
|
|
var jsonPath = Path.Combine(dir, "plugin.json");
|
|
File.WriteAllText(jsonPath, """{"name":"my-plugin","version":"0.1.0","description":"A plugin.","skills":"./skills/","agents":"./agents/"}""");
|
|
|
|
var plugin = PluginDiscovery.ParsePluginJson(jsonPath);
|
|
Assert.NotNull(plugin);
|
|
Assert.Single(plugin.SkillPaths);
|
|
Assert.Equal("./skills/", plugin.SkillPaths[0]);
|
|
Assert.Single(plugin.AgentPaths);
|
|
Assert.Equal("./agents/", plugin.AgentPaths[0]);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(dir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ParsePluginJsonNoAgentsField()
|
|
{
|
|
var dir = Path.Combine(Path.GetTempPath(), "parse-test-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
Directory.CreateDirectory(dir);
|
|
var jsonPath = Path.Combine(dir, "plugin.json");
|
|
File.WriteAllText(jsonPath, """{"name":"my-plugin","version":"0.1.0","description":"A plugin.","skills":"./skills/"}""");
|
|
|
|
var plugin = PluginDiscovery.ParsePluginJson(jsonPath);
|
|
Assert.NotNull(plugin);
|
|
Assert.Empty(plugin.AgentPaths);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(dir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ParsePluginJsonThrowsOnMalformedJson()
|
|
{
|
|
var dir = Path.Combine(Path.GetTempPath(), "parse-test-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
Directory.CreateDirectory(dir);
|
|
var jsonPath = Path.Combine(dir, "plugin.json");
|
|
File.WriteAllText(jsonPath, "{ not valid json!!!");
|
|
|
|
Assert.Throws<JsonException>(() => PluginDiscovery.ParsePluginJson(jsonPath));
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(dir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AbsoluteSkillsPathErrors()
|
|
{
|
|
var plugin = new PluginInfo("test", "1.0.0", "desc", ["/etc/skills/"], [], "/tmp/test", "test");
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Contains(result.Errors, e => e.Contains("invalid") && e.Contains("absolute"));
|
|
}
|
|
|
|
[Fact]
|
|
public void TraversalSkillsPathErrors()
|
|
{
|
|
var pluginDir = Path.Combine(Path.GetTempPath(), "plugin-test-" + Guid.NewGuid().ToString("N"));
|
|
try
|
|
{
|
|
Directory.CreateDirectory(pluginDir);
|
|
var dirName = Path.GetFileName(pluginDir);
|
|
var plugin = new PluginInfo(dirName, "1.0.0", "desc", ["../../../etc/"], [], pluginDir, dirName);
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Contains(result.Errors, e => e.Contains("invalid") && e.Contains("outside"));
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(pluginDir, true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingNameFallsBackToDirectoryName()
|
|
{
|
|
var plugin = new PluginInfo("", "1.0.0", "desc", ["./skills/"], [], "/tmp/my-plugin", "my-plugin");
|
|
var result = PluginProfiler.ValidatePlugin(plugin);
|
|
Assert.Equal("my-plugin", result.Name);
|
|
}
|
|
}
|
|
|