Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions internal/app/azldev/agentskill/agentskill.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,81 @@ var skills = []Skill{
"component, distro config.",
bodyTemplate: "azldev.md.tmpl",
},
{
Name: "azldev-mock",
Description: "Read this before testing or inspecting a built RPM; do not drive mock by hand from " +
"memory. Explains how to test and inspect built packages in a mock chroot with 'azldev adv " +
"mock shell', covering non-interactive (heredoc) and interactive chroot workflows, the " +
"-p/--add-package flag, and resetting stale chroot state. Triggers include test package, mock " +
"shell, inspect rpm, smoke test, chroot, verify build output.",
bodyTemplate: "mock.md.tmpl",
},
{
Name: "azldev-update-component",
Description: "Read this before finalizing a component change, changing source resolution, or " +
"touching a lock file; lock edits are easy to get wrong. Explains how to refresh azldev " +
"component lock files with 'azldev comp update', covering when to run update versus render, the " +
"update/render/commit/re-render/amend workflow, and per-component versus -a refresh. Triggers " +
"include comp update, refresh lock, bump pin, change snapshot, upstream distro, lock drift, " +
"version bump, finalize component.",
bodyTemplate: "update-component.md.tmpl",
},
{
Name: "azldev-remove-component",
Description: "Read this before deleting or dropping a component; there is no azldev remove " +
"command, so doing it wrong leaves dangling state. Explains the manual removal workflow for " +
"deleting component metadata, cleaning references, and validating any related output-affecting " +
"changes. Triggers include remove component, delete package, drop " +
"component, prune dependency.",
bodyTemplate: "remove-component.md.tmpl",
},
{
Name: "azldev-overlays",
Description: "Read this before adding, changing, or diagnosing any overlay; never edit a spec or " +
"rendered file from memory. Explains how to modify a component's RPM spec or loose source files " +
"with azldev overlays (semantic patches applied at render time) instead of forking the spec, " +
"covering overlay types, the render-and-inspect loop, common failures, pitfalls, and metadata. " +
"Triggers include overlay, overlay failed, no match, spec-add-tag, spec-remove-tag, patch-add, " +
"fix spec, backport, disable test, prune subpackage, edit spec.",
bodyTemplate: "overlays.md.tmpl",
},
{
Name: "azldev-comp-toml",
Description: "Read this before authoring, editing, or reviewing a *.comp.toml file; do not work " +
"from memory. Explains the azldev component definition format and review workflow, covering " +
"component structure, spec sources, build config, release calculation, render options, file " +
"organization, overlay hygiene, stale files, disabled tests, and testing verification. " +
"Triggers include comp.toml, component config, review component, component hygiene, spec " +
"source, upstream-distro, build defines, release calculation, includes.",
bodyTemplate: "comp-toml.md.tmpl",
},
{
Name: "azldev-add-component",
Description: "Read this before adding or importing a component; follow the workflow instead of " +
"guessing. Explains how to add a new component to an azldev distro, covering inspecting the " +
"upstream spec, the inline-versus-dedicated-file decision, and validating with render, " +
"diff-sources, and build. Triggers include add component, new package, import package, create " +
"comp.toml, new component.",
bodyTemplate: "add-component.md.tmpl",
},
{
Name: "azldev-build-component",
Description: "Read this before building a component or diagnosing a build failure; do not guess " +
"build flags or the inner loop. Explains how to build, iterate on, and debug an azldev " +
"component, covering comp build flags (local-repo, preserve-buildenv), the render/build/test " +
"inner loop, diff-sources, and disabling a failing %check via check.skip. Triggers include " +
"build component, build failed, build error, inner loop, preserve buildenv, local repo, disable " +
"check.",
bodyTemplate: "build-component.md.tmpl",
},
{
Name: "azldev-image",
Description: "Read this before building, booting, or configuring an azldev image. Explains the " +
"azldev image commands (list, build, boot, test, customize) and the [images.<name>] config " +
"(kiwi definition, capabilities, tests, publish); the kiwi XML format itself is upstream KIWI " +
"NG. Triggers include image build, image boot, kiwi, container image, VM image, images.toml.",
bodyTemplate: "image.md.tmpl",
},
}

// Skills returns the registered skills in emission order.
Expand Down Expand Up @@ -146,6 +221,40 @@ var instructions = []Instruction{
{Skill: SkillName, Purpose: "for how to use the azldev CLI"},
},
},
{
Name: "comp-toml",
ApplyTo: "**/*.comp.toml,**/components.toml",
Description: "These are azldev component definition files (*.comp.toml). Before editing " +
"or reviewing one, load " +
"the azldev-comp-toml skill (and azldev-overlays for spec changes); do not hand-write " +
"component config from memory. Triggers include comp.toml, component config, spec source, " +
"build defines, release calculation, overlays, review component.",
Title: "Component definition files (`*.comp.toml`)",
Intro: "These files define a distro's components — each one's spec source and how azldev customizes it.",
Skills: []SkillPointer{
{Skill: "azldev-comp-toml", Purpose: "for the component TOML format and review checklist"},
{Skill: "azldev-add-component", Purpose: "to add a new component"},
{Skill: "azldev-overlays", Purpose: "to add or change overlays"},
{Skill: "azldev-update-component", Purpose: "to refresh a component's lock"},
{Skill: "azldev-remove-component", Purpose: "to remove a component"},
},
},
{
Name: "rendered-specs",
ApplyTo: "{{ .RenderedSpecsDir }}/**/*",
Description: "Rendered component files produced by 'azldev comp render'. They are build inputs and " +
"must not be hand-edited. Before changing one, load the azldev-overlays or azldev-comp-toml " +
"skill, edit the source, and re-render. Read this when viewing or tempted to edit generated " +
"output.",
Title: "Rendered component files",
Intro: "These files are generated by `azldev comp render` and are build inputs; do not edit them " +
"directly — change the component's `.comp.toml`, overlays, or source files and re-render.",
Skills: []SkillPointer{
{Skill: "azldev-comp-toml", Purpose: "for the component TOML format"},
{Skill: "azldev-overlays", Purpose: "to change generated output via overlays"},
{Skill: "azldev-update-component", Purpose: "to refresh and finalize a component"},
},
},
}

// Instructions returns the registered instruction files in emission order.
Expand Down
141 changes: 141 additions & 0 deletions internal/app/azldev/agentskill/agentskill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ package agentskill_test

import (
"path"
"reflect"
"strings"
"testing"

"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/agentskill"
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -78,6 +80,29 @@ func TestSkillDocumentUnknown(t *testing.T) {
require.Error(t, err)
}

func TestSkillDocumentUsesBindings(t *testing.T) {
params := testParams()
params.Bindings = agentskill.Bindings{
LockDir: "build/locks",
RenderedSpecsDir: "build/specs",
}

doc, err := agentskill.SkillDocument("azldev-remove-component", params)
require.NoError(t, err)

// The resolved binding values, not azldev's defaults, appear in the rendered body.
assert.Contains(t, doc, "build/locks/<name>.lock")
assert.Contains(t, doc, "build/specs/")
}

func TestUpdateComponentSkillStagesRenderedOutputBeforeAmend(t *testing.T) {
doc, err := agentskill.SkillDocument("azldev-update-component", testParams())
require.NoError(t, err)

assert.Equal(t, 2, strings.Count(doc, "git add specs/<first-char>/<name>/"),
"both amend workflows must stage the post-commit render")
}

func TestSkillFrontmatterInvariants(t *testing.T) {
layout := agentskill.DefaultLayout()

Expand Down Expand Up @@ -115,6 +140,15 @@ func fileByPath(t *testing.T, files []agentskill.EmittedFile, relPath string) ag
return agentskill.EmittedFile{}
}

func mockSkill(t *testing.T) agentskill.Skill {
t.Helper()

skill, err := agentskill.FindSkill("azldev-mock")
require.NoError(t, err)

return skill
}

// instructionByName returns the registered instruction with the given name.
func instructionByName(t *testing.T, name string) agentskill.Instruction {
t.Helper()
Expand All @@ -137,6 +171,19 @@ func TestInstructionsRegistry(t *testing.T) {
}

assert.Contains(t, names, "azldev")
assert.Contains(t, names, "comp-toml")
assert.Contains(t, names, "rendered-specs")

compToml := instructionByName(t, "comp-toml")
assert.Equal(t, "**/*.comp.toml,**/components.toml", compToml.ApplyTo)

skillNames := make([]string, 0, len(compToml.Skills))
for _, pointer := range compToml.Skills {
skillNames = append(skillNames, pointer.Skill)
}

assert.Contains(t, skillNames, "azldev-comp-toml")
assert.Contains(t, skillNames, "azldev-overlays")
}

func TestSkillsRegistry(t *testing.T) {
Expand All @@ -146,6 +193,14 @@ func TestSkillsRegistry(t *testing.T) {
}

assert.Contains(t, names, agentskill.SkillName)
assert.Contains(t, names, "azldev-mock")
assert.Contains(t, names, "azldev-update-component")
assert.Contains(t, names, "azldev-remove-component")
assert.Contains(t, names, "azldev-overlays")
assert.Contains(t, names, "azldev-comp-toml")
assert.Contains(t, names, "azldev-add-component")
assert.Contains(t, names, "azldev-build-component")
assert.Contains(t, names, "azldev-image")
}

func TestRegistryAccessorsReturnCopies(t *testing.T) {
Expand Down Expand Up @@ -196,6 +251,11 @@ func TestFilesWrapper(t *testing.T) {
assert.Contains(t, skill, agentskill.ShowSkillToolName)
assert.NotContains(t, skill, "Golden rules")

// The mock wrapper also points at the tool but is not the full body.
mockWrapper := fileByPath(t, files, layout.SkillFile(mockSkill(t))).Content
assert.Contains(t, mockWrapper, agentskill.ShowSkillToolName)
assert.NotContains(t, mockWrapper, "Never install built RPMs")

// The azldev instruction wrapper applies to azldev.toml and points at the azldev skill by
// name (never the CLI/MCP tool, which may be unavailable in --full installs).
azldevInstruction := instructionByName(t, "azldev")
Expand All @@ -205,6 +265,39 @@ func TestFilesWrapper(t *testing.T) {
assert.Contains(t, instructions, "`"+agentskill.SkillName+"`")
assert.NotContains(t, instructions, agentskill.ShowSkillToolName)
assert.NotContains(t, instructions, "docs agent show")

// The comp-toml instruction wrapper applies to *.comp.toml and points at its skills by
// name and purpose.
compTomlInstruction := instructionByName(t, "comp-toml")
compTomlWrapper := fileByPath(t, files, agentskill.InstructionFile(compTomlInstruction)).Content
assert.Contains(t, compTomlWrapper, `applyTo: "**/*.comp.toml,**/components.toml"`)
assert.Contains(t, compTomlWrapper, "read the `azldev-comp-toml` skill")
assert.Contains(t, compTomlWrapper, "Read the `azldev-overlays` skill to add or change overlays")
// The file-format skill is required; mutually exclusive workflow skills are conditional.
assert.Contains(t, compTomlWrapper, "You MUST read the `azldev-comp-toml` skill")
assert.NotContains(t, compTomlWrapper, "You MUST read the `azldev-overlays` skill")
assert.NotContains(t, compTomlWrapper, "docs agent show")

// The rendered-specs wrapper carries the do-not-edit guardrail and a binding-resolved glob.
renderedSpecsInstruction := instructionByName(t, "rendered-specs")
renderedSpecsWrapper := fileByPath(t, files, agentskill.InstructionFile(renderedSpecsInstruction)).Content
assert.Contains(t, renderedSpecsWrapper, `applyTo: "specs/**/*"`)
assert.Contains(t, renderedSpecsWrapper, "do not edit them directly")
}

func TestRenderedSpecsInstructionApplyToTracksBindings(t *testing.T) {
layout := agentskill.DefaultLayout()
params := testParams()
params.RenderedSpecsDir = "SPECS"

files, err := agentskill.Files(layout, params, false)
require.NoError(t, err)

inst := instructionByName(t, "rendered-specs")
wrapper := fileByPath(t, files, agentskill.InstructionFile(inst)).Content

// The applyTo glob tracks the configured rendered-specs directory.
assert.Contains(t, wrapper, `applyTo: "SPECS/**/*"`)
}

func TestFilesFull(t *testing.T) {
Expand All @@ -220,6 +313,7 @@ func TestFilesFull(t *testing.T) {

// In full mode each on-disk SKILL.md inlines the complete skill document.
assert.Contains(t, fileByPath(t, files, layout.SkillFile(primarySkill(t))).Content, "overlay system")
assert.Contains(t, fileByPath(t, files, layout.SkillFile(mockSkill(t))).Content, "azldev adv mock shell")
}

func TestFilesGitHubLayout(t *testing.T) {
Expand All @@ -234,4 +328,51 @@ func TestFilesGitHubLayout(t *testing.T) {
// The github layout places skills under .github/skills with their plain (namespaced) names.
azldevSkill := fileByPath(t, files, ".github/skills/azldev/SKILL.md")
assert.Contains(t, azldevSkill.Content, "name: azldev")

mockFile := fileByPath(t, files, ".github/skills/azldev-mock/SKILL.md")
assert.Contains(t, mockFile.Content, "name: azldev-mock")
}

// schemaEnum extracts values from an authoritative jsonschema enum tag.
func schemaEnum(t *testing.T, structType reflect.Type, fieldName string) []string {
t.Helper()

field, ok := structType.FieldByName(fieldName)
require.Truef(t, ok, "%s must have a %s field", structType.Name(), fieldName)

var values []string

for _, part := range strings.Split(field.Tag.Get("jsonschema"), ",") {
if value, found := strings.CutPrefix(part, "enum="); found {
values = append(values, value)
}
}

require.NotEmptyf(t, values, "expected enum values in %s.%s jsonschema tag", structType.Name(), fieldName)

return values
}

// TestOverlaysSkillCoversSchemaEnums is a drift guard: the azldev-overlays skill must
// document every overlay type, metadata category, and upstream status defined in code.
func TestOverlaysSkillCoversSchemaEnums(t *testing.T) {
doc, err := agentskill.SkillDocument("azldev-overlays", agentskill.Params{})
require.NoError(t, err)

schemaFields := []struct {
structType reflect.Type
fieldName string
}{
{reflect.TypeOf(projectconfig.ComponentOverlay{}), "Type"},
{reflect.TypeOf(projectconfig.OverlayMetadata{}), "Category"},
{reflect.TypeOf(projectconfig.OverlayMetadata{}), "UpstreamStatus"},
}

for _, schemaField := range schemaFields {
for _, value := range schemaEnum(t, schemaField.structType, schemaField.fieldName) {
assert.Containsf(t, doc, "`"+value+"`",
"azldev-overlays skill must document %s.%s value %q",
schemaField.structType.Name(), schemaField.fieldName, value)
}
}
}
Loading
Loading