Files
dotnet__skills/eng/skill-validator/tests/AgentPluginTests.cs
Dan Moseley 8e50fc8558 Add agent and plugin spec conformance validation (#279)
* Add agent and plugin spec conformance validation

Extend the validation pipeline to check .agent.md files and plugin.json
files alongside skills. Previously only SKILL.md files were validated.

Agent validation (AgentProfiler):
- Frontmatter required (error)
- Name and description required (error)
- Name format: same rules as skills (lowercase alphanum + hyphens, 64 max)
- Name must match filename minus .agent.md suffix
- Description max 1024 chars
- Body max 500 lines

Plugin validation (PluginValidator):
- Name required, must match directory name
- Version and description required
- Description max 1024 chars
- Skills path required and must exist
- Agents path warned if specified but missing

Discovery (SkillDiscovery):
- DiscoverAgents() walks up from skill paths to plugin roots,
  scans agents/ directories for .agent.md files
- DiscoverPlugins() finds plugin.json from skill paths

Integration (ValidateCommand):
- Runs after skill discovery, before evaluation
- Errors block with exit code 1 (same as spec_conformance_failure)

All 4 existing agents and 2 plugins pass cleanly.
25 new tests (13 agent, 12 plugin).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Code review fixes: spec comments, ValidateNameFormat, kind-specific messages

- Refactor ValidateName into ValidateNameFormat (format-only) + ValidateName
  (format + directory match) so agent/plugin validation doesn't abuse the
  skill directory-match check by passing name twice.
- Warning messages now say 'Agent name' or 'Plugin name' instead of
  'Skill name' when called from AgentProfiler/PluginValidator.
- Add spec URL comments to AgentProfiler and PluginValidator referencing
  agentskills.io/specification, VS Code agent-plugins docs, and Claude Code
  plugins-reference.
- Add missing agent name edge-case tests: leading/trailing hyphen,
  consecutive hyphens, and message-prefix assertions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove unused agents path from plugins that have no agents

The dotnet, dotnet-data, and dotnet-upgrade plugins declared an agents
path but had no agents/ directory, producing validation warnings. Remove
the field; it can be added back when agents are actually created.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Promote agent/plugin name validation from warnings to errors

The agentskills.io spec uses 'Must' for all name constraints (length,
character set, no leading/trailing hyphens, no consecutive hyphens).
For a publishing validator these should block, not just warn. Agent
filename mismatch is also promoted since the IDE won't discover the
agent if the name doesn't match.

Skill name validation stays as warnings (lenient loading, not part
of this PR).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review feedback

- Fix FrontmatterRegex: remove RegexOptions.Multiline so a horizontal
  rule (---) in the body is not mistaken for frontmatter. Detection and
  stripping now both anchor to start of string.
- Use filename as fallback identifier in AgentProfile when agent.Name
  is empty, so error output shows [agent:foo.agent.md] not [agent:].
- Add TryGetSafeSubdirectory to PluginValidator: rejects absolute paths
  and parent-directory traversal in skills/agents path fields. Used by
  both PluginValidator and SkillDiscovery.DiscoverAgents.
- Use DirectoryName as fallback for plugin result name when Name is
  empty, so error output shows [plugin:my-dir] not [plugin:].
- Add tests for path traversal rejection and name fallback behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Detect orphaned test directories with no matching plugin/skill

Add FindOrphanedTestDirectories to SkillDiscovery: checks that every
directory under tests/{plugin}/{skill}/ has a corresponding directory
under plugins/{plugin}/skills/{skill}/. Plugin-level orphans (tests/
directory with no matching plugins/ directory) are also flagged.

Integrated into ValidateCommand after plugin/agent validation so it
runs on every validation pass. Errors are blocking (exit 1).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR #279 review feedback

- Use case-sensitive (Ordinal) comparison for agent filename matching
  since agent names are required to be lowercase by spec.
- Replace OrdinalIgnoreCase path-under-root check in TryGetSafeSubdirectory
  with Path.GetRelativePath + '..' prefix check for correct behavior on
  case-sensitive filesystems.
- Let ParsePluginJson throw JsonException on malformed JSON instead of
  silently returning null. ValidateCommand catches it as a blocking error.
- Add test for malformed plugin.json.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Increase eval timeouts for dump-collect and dotnet-trace-collect to 120s

The with-skill runs for these skills were hitting the 60s scenario timeout
in CI, causing the agent to be killed before producing output. Other skills
in the same plugin already use 120s. The 30s decline/negative-test scenario
is left unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix fixture paths in clr-activation-debugging and analyzing-dotnet-performance evals

The source paths referenced tests/dotnet/ but the fixtures live under
tests/dotnet-diag/. This caused file-not-found exceptions at runtime,
which set hasRejections and caused unconditional exit 1 (bypassing
--verdict-warn-only).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Surface execution errors in validation summary

Previously, skills that threw exceptions (e.g. missing fixture files)
were silently excluded from the verdict count. The summary showed
'2/4 skills passed' with no indication that 2 other skills crashed
entirely. Now the summary shows '2/6 skills passed (2 rejected due
to execution errors)' and lists the specific error messages near
the summary rather than buried in the middle of the log.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Reduce parallelism for infra-change evaluation runs

When infrastructure changes trigger evaluation of all plugins
simultaneously, the high default parallelism (5x5x5) causes API
rate limits (429s) and scenario timeouts from contention. Reduce
to 2x3x3 for infra-change runs only; regular single-plugin PR
evaluations keep the default 5x5x5.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 15:40:06 +01:00

450 lines
16 KiB
C#

using System.Text.Json;
using SkillValidator.Models;
using SkillValidator.Services;
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 BodyOver500LinesErrors()
{
var body = string.Join("\n", Enumerable.Range(1, 501).Select(i => $"Line {i}"));
var content = "---\nname: test-agent\ndescription: test\n---\n" + body;
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content));
Assert.Contains(profile.Errors, e => e.Contains("lines") && e.Contains("500"));
}
[Fact]
public void BodyAt500LinesNoError()
{
var body = string.Join("\n", Enumerable.Range(1, 500).Select(i => $"Line {i}"));
var content = "---\nname: test-agent\ndescription: test\n---\n" + body;
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content));
Assert.DoesNotContain(profile.Errors, e => e.Contains("lines") && e.Contains("500"));
}
[Fact]
public void BodyAt500LinesWithTrailingNewlineNoError()
{
var body = string.Join("\n", Enumerable.Range(1, 500).Select(i => $"Line {i}")) + "\n";
var content = "---\nname: test-agent\ndescription: test\n---\n" + body;
var profile = AgentProfiler.AnalyzeAgent(MakeAgent(content));
Assert.DoesNotContain(profile.Errors, e => e.Contains("lines") && e.Contains("500"));
}
[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 PluginValidatorTests
{
[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"));
var dirName = Path.GetFileName(pluginDir);
var plugin = new PluginInfo(dirName, "1.0.0", "A test plugin.", "./skills/", "./agents/", pluginDir, dirName);
var result = PluginValidator.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/", null, "/tmp/test", "test");
var result = PluginValidator.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/", null, "/tmp/my-plugin", "my-plugin");
var result = PluginValidator.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/", null, "/tmp/test", "test");
var result = PluginValidator.ValidatePlugin(plugin);
Assert.Contains(result.Errors, e => e.Contains("version"));
}
[Fact]
public void MissingDescriptionErrors()
{
var plugin = new PluginInfo("test", "1.0.0", null, "./skills/", null, "/tmp/test", "test");
var result = PluginValidator.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/", null, "/tmp/test", "test");
var result = PluginValidator.ValidatePlugin(plugin);
Assert.Contains(result.Errors, e => e.Contains("maximum"));
}
[Fact]
public void MissingSkillsPathErrors()
{
var plugin = new PluginInfo("test", "1.0.0", "desc", null, null, "/tmp/test", "test");
var result = PluginValidator.ValidatePlugin(plugin);
Assert.Contains(result.Errors, e => e.Contains("skills"));
}
[Fact]
public void NonexistentSkillsPathErrors()
{
var plugin = new PluginInfo("test", "1.0.0", "desc", "./nonexistent/", null, "/tmp/test", "test");
var result = PluginValidator.ValidatePlugin(plugin);
Assert.Contains(result.Errors, e => e.Contains("does not exist"));
}
[Fact]
public void NonexistentAgentsPathWarns()
{
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/", pluginDir, dirName);
var result = PluginValidator.ValidatePlugin(plugin);
Assert.Empty(result.Errors);
Assert.Contains(result.Warnings, w => w.Contains("does not exist"));
}
finally
{
Directory.Delete(pluginDir, true);
}
}
[Fact]
public void NameFormatErrors()
{
var plugin = new PluginInfo("My_Plugin", "1.0.0", "desc", "./skills/", null, "/tmp/My_Plugin", "My_Plugin");
var result = PluginValidator.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/", null, "/tmp/My_Plugin", "My_Plugin");
var result = PluginValidator.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 = PluginValidator.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 = PluginValidator.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.Equal("./skills/", plugin.SkillsPath);
Assert.Equal("./agents/", plugin.AgentsPath);
}
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>(() => PluginValidator.ParsePluginJson(jsonPath));
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public void AbsoluteSkillsPathErrors()
{
var plugin = new PluginInfo("test", "1.0.0", "desc", "/etc/skills/", null, "/tmp/test", "test");
var result = PluginValidator.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/", null, pluginDir, dirName);
var result = PluginValidator.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/", null, "/tmp/my-plugin", "my-plugin");
var result = PluginValidator.ValidatePlugin(plugin);
Assert.Equal("my-plugin", result.Name);
}
}
public class OrphanedTestDirectoryTests
{
[Fact]
public void NoOrphansWhenTestsMatchPlugins()
{
var root = Path.Combine(Path.GetTempPath(), "orphan-test-" + Guid.NewGuid().ToString("N"));
try
{
// Create matching structure: plugins/my-plugin/skills/my-skill + tests/my-plugin/my-skill
Directory.CreateDirectory(Path.Combine(root, "plugins", "my-plugin", "skills", "my-skill"));
Directory.CreateDirectory(Path.Combine(root, "tests", "my-plugin", "my-skill"));
var orphans = SkillDiscovery.FindOrphanedTestDirectories(root);
Assert.Empty(orphans);
}
finally
{
Directory.Delete(root, true);
}
}
[Fact]
public void OrphanedPluginTestDirectory()
{
var root = Path.Combine(Path.GetTempPath(), "orphan-test-" + Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(Path.Combine(root, "plugins", "real-plugin", "skills", "a-skill"));
Directory.CreateDirectory(Path.Combine(root, "tests", "no-such-plugin", "a-skill"));
var orphans = SkillDiscovery.FindOrphanedTestDirectories(root);
Assert.Single(orphans);
Assert.Contains("no-such-plugin", orphans[0]);
}
finally
{
Directory.Delete(root, true);
}
}
[Fact]
public void OrphanedSkillTestDirectory()
{
var root = Path.Combine(Path.GetTempPath(), "orphan-test-" + Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(Path.Combine(root, "plugins", "my-plugin", "skills", "real-skill"));
Directory.CreateDirectory(Path.Combine(root, "tests", "my-plugin", "real-skill"));
Directory.CreateDirectory(Path.Combine(root, "tests", "my-plugin", "ghost-skill"));
var orphans = SkillDiscovery.FindOrphanedTestDirectories(root);
Assert.Single(orphans);
Assert.Contains("ghost-skill", orphans[0]);
}
finally
{
Directory.Delete(root, true);
}
}
[Fact]
public void ReturnsEmptyWhenNoTestsDirectory()
{
var root = Path.Combine(Path.GetTempPath(), "orphan-test-" + Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(Path.Combine(root, "plugins", "my-plugin"));
var orphans = SkillDiscovery.FindOrphanedTestDirectories(root);
Assert.Empty(orphans);
}
finally
{
Directory.Delete(root, true);
}
}
}