Compare commits

...

1 Commits

Author SHA1 Message Date
J
c25bc20551 fix(skills): align Go/TS frontmatter coercion for non-scalar values
The Go SKILL.md frontmatter parser unmarshalled into a {Name,Description}
string struct, so a non-scalar value (a list/map written where a scalar
belongs) made the whole decode fail and dropped even a valid sibling
`name`. The TS parser instead kept the name and JSON-encoded the value,
so the file-viewer (TS) and the import path (Go) could disagree about
the same SKILL.md.

Decode into a generic map and coerce per key on the Go side, mirroring
the TS coercion (scalars -> literal form, sequences/mappings -> JSON), so
both sides produce identical results and a structured value never
discards a sibling key. Rename ParseFrontmatter -> ParseSkillFrontmatter
to remove the cross-language name clash with the TS parseFrontmatter
(which returns {frontmatter, body}), and drop the unused TS
parseSkillFrontmatter export.

Add parity tests for sequence/mapping values plus name-only,
description-only, leading-blank-line and triple-dash-in-body edge cases
on both sides.

Follow-up to #3543 / MUL-2842.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 19:39:19 +08:00
6 changed files with 120 additions and 26 deletions

View File

@@ -97,4 +97,27 @@ describe("parseFrontmatter", () => {
enabled: "true",
});
});
// A structured value where a scalar belongs (authoring mistake) must keep the
// sibling name and JSON-encode the value. This mirrors the Go
// ParseSkillFrontmatter behaviour in server/internal/skill so both sides agree.
it("keeps sibling name and JSON-encodes a sequence value", () => {
const { frontmatter } = parseFrontmatter(
"---\nname: my-skill\ndescription:\n - first feature\n - second feature\n---\nbody",
);
expect(frontmatter).toEqual({
name: "my-skill",
description: '["first feature","second feature"]',
});
});
it("keeps sibling name and JSON-encodes a mapping value", () => {
const { frontmatter } = parseFrontmatter(
"---\nname: my-skill\ndescription:\n a: 1\n b: 2\n---\nbody",
);
expect(frontmatter).toEqual({
name: "my-skill",
description: '{"a":1,"b":2}',
});
});
});

View File

@@ -47,14 +47,3 @@ export function parseFrontmatter(raw: string): ParsedFrontmatter {
body,
};
}
export function parseSkillFrontmatter(content: string): {
name: string;
description: string;
} {
const { frontmatter } = parseFrontmatter(content);
return {
name: frontmatter?.name ?? "",
description: frontmatter?.description ?? "",
};
}

View File

@@ -318,7 +318,7 @@ func enumerateLocalSkills(
if err != nil {
continue
}
skillName, description := skill.ParseFrontmatter(content)
skillName, description := skill.ParseSkillFrontmatter(content)
if skillName == "" {
skillName = filepath.Base(path)
}
@@ -376,7 +376,7 @@ func loadRuntimeLocalSkillBundle(provider, skillKey string) (*runtimeLocalSkillB
if err != nil {
return nil, true, err
}
name, description := skill.ParseFrontmatter(content)
name, description := skill.ParseSkillFrontmatter(content)
if name == "" {
name = filepath.Base(skillDir)
}

View File

@@ -996,7 +996,7 @@ func fetchFromSkillsSh(httpClient *http.Client, rawURL string) (*importedSkill,
if skillMdBody == nil {
body, err := fetchRawFile(httpClient, buildRawGitHubURL(rawPrefix, "SKILL.md"))
if err == nil {
if name, _ := skill.ParseFrontmatter(string(body)); name == skillName {
if name, _ := skill.ParseSkillFrontmatter(string(body)); name == skillName {
skillMdBody = body
skillDir = ""
}
@@ -1010,7 +1010,7 @@ func fetchFromSkillsSh(httpClient *http.Client, rawURL string) (*importedSkill,
}
// Parse name and description from YAML frontmatter
name, description := skill.ParseFrontmatter(string(skillMdBody))
name, description := skill.ParseSkillFrontmatter(string(skillMdBody))
if name == "" {
name = skillName
}
@@ -1282,7 +1282,7 @@ func findMatchingSkillDirByFrontmatter(httpClient *http.Client, rawPrefix, skill
slog.Warn("github import: fallback SKILL.md fetch failed", "path", skillPath, "error", err)
continue
}
name, _ := skill.ParseFrontmatter(string(body))
name, _ := skill.ParseSkillFrontmatter(string(body))
if name == skillName {
return skillDirFromSkillFilePath(skillPath), body, true
}
@@ -1570,7 +1570,7 @@ func fetchFromGitHub(httpClient *http.Client, rawURL string) (*importedSkill, er
skillMdPath, spec.owner, spec.repo, spec.ref, err)
}
name, description := skill.ParseFrontmatter(string(skillMdBody))
name, description := skill.ParseSkillFrontmatter(string(skillMdBody))
if name == "" {
if spec.skillDir != "" {
name = filepath.Base(spec.skillDir)

View File

@@ -2,7 +2,9 @@
package skill
import (
"encoding/json"
"regexp"
"strconv"
"strings"
"gopkg.in/yaml.v3"
@@ -12,11 +14,17 @@ import (
// chomping only preserves a final newline when the input itself contains one.
var frontmatterPattern = regexp.MustCompile(`(?s)\A---\r?\n(.*?\r?\n)---`)
// ParseFrontmatter extracts name and description from the YAML frontmatter
// ParseSkillFrontmatter extracts name and description from the YAML frontmatter
// block of a SKILL.md file. Returns empty strings when the frontmatter is
// absent or malformed so callers can keep treating missing metadata as a
// non-fatal condition, matching the behaviour of the legacy line-based parser.
func ParseFrontmatter(content string) (name, description string) {
//
// Values are decoded into a generic map and coerced per key (scalars via their
// literal form, sequences/mappings via JSON) rather than unmarshalled into a
// string struct. This means a structured value in one field never discards a
// valid sibling key, and the coercion mirrors the TS parseFrontmatter in
// packages/core/skills/frontmatter.ts so both sides agree on the same input.
func ParseSkillFrontmatter(content string) (name, description string) {
if !strings.HasPrefix(content, "---") {
return "", ""
}
@@ -25,12 +33,37 @@ func ParseFrontmatter(content string) (name, description string) {
return "", ""
}
var fm struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
}
var fm map[string]any
if err := yaml.Unmarshal([]byte(match[1]), &fm); err != nil {
return "", ""
}
return fm.Name, fm.Description
return coerceFrontmatterValue(fm["name"]), coerceFrontmatterValue(fm["description"])
}
// coerceFrontmatterValue renders a decoded YAML value as a string, mirroring the
// TS side: nil becomes empty, strings pass through, other scalars use their
// literal form, and structured values (sequences/mappings) are JSON-encoded.
func coerceFrontmatterValue(v any) string {
switch val := v.(type) {
case nil:
return ""
case string:
return val
case bool:
return strconv.FormatBool(val)
case int:
return strconv.Itoa(val)
case int64:
return strconv.FormatInt(val, 10)
case uint64:
return strconv.FormatUint(val, 10)
case float64:
return strconv.FormatFloat(val, 'g', -1, 64)
default:
encoded, err := json.Marshal(val)
if err != nil {
return ""
}
return string(encoded)
}
}

View File

@@ -2,7 +2,7 @@ package skill
import "testing"
func TestParseFrontmatter(t *testing.T) {
func TestParseSkillFrontmatter(t *testing.T) {
tests := []struct {
name string
content string
@@ -69,6 +69,55 @@ func TestParseFrontmatter(t *testing.T) {
wantName: "",
wantDesc: "",
},
{
name: "name only",
content: "---\nname: foo\n---\nbody",
wantName: "foo",
wantDesc: "",
},
{
name: "description only",
content: "---\ndescription: bar\n---\nbody",
wantName: "",
wantDesc: "bar",
},
{
name: "leading blank line is not frontmatter",
content: "\n---\nname: foo\ndescription: bar\n---\nbody",
wantName: "",
wantDesc: "",
},
{
// The non-greedy capture stops at the first closing fence, so a
// later "---" in the body must not extend the frontmatter block.
name: "triple dash in body stops at first fence",
content: "---\nname: foo\ndescription: bar\n---\nintro\n---\nmore",
wantName: "foo",
wantDesc: "bar",
},
{
// Parity with the TS coercion: non-string scalars render as their
// literal form rather than being dropped.
name: "non-string scalars coerce to literal",
content: "---\nname: 123\ndescription: 456\n---\nbody",
wantName: "123",
wantDesc: "456",
},
{
// A structured value where a scalar belongs (authoring mistake) must
// not discard the sibling name; the value is JSON-encoded, matching
// the TS parseFrontmatter behaviour.
name: "sequence description keeps name and is JSON-encoded",
content: "---\nname: my-skill\ndescription:\n - first feature\n - second feature\n---\nbody",
wantName: "my-skill",
wantDesc: `["first feature","second feature"]`,
},
{
name: "mapping description keeps name and is JSON-encoded",
content: "---\nname: my-skill\ndescription:\n a: 1\n b: 2\n---\nbody",
wantName: "my-skill",
wantDesc: `{"a":1,"b":2}`,
},
{
// Reproduction for issue #3495: Chinese block scalar.
name: "issue 3495 chinese literal block scalar",
@@ -88,7 +137,7 @@ func TestParseFrontmatter(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotName, gotDesc := ParseFrontmatter(tt.content)
gotName, gotDesc := ParseSkillFrontmatter(tt.content)
if gotName != tt.wantName {
t.Errorf("name: got %q, want %q", gotName, tt.wantName)
}