Files
multica/server/internal/daemon/execenv/skill_frontmatter_test.go
Bohan Jiang 5199278780 fix(skills): give every skill one name across brief, directory, and frontmatter (MUL-5529) (#6189)
* fix(skills): give every skill one name across brief, directory, and frontmatter (MUL-5529)

A skill could answer to three different names at once. The runtime brief listed
`AgentSkillData.Name` verbatim — a workspace skill's human display name ("PR
review") — while the invocable identity on disk is the sanitized slug
(`pr-review`). Separately, ensureSkillFrontmatter returned valid upstream
frontmatter untouched, so a SKILL.md could declare `name: multica-dev-workflow`
inside a directory called `multica-git-workflow`.

That last divergence is the sharp one: runtimes disagree on which field
identifies a skill. Claude routes on the directory name, OpenCode on the
frontmatter `name`. So the same skill is invocable under different names
depending on where it runs, and the brief's instruction to use "only names from
the listing" pointed at names that resolve nowhere.

The slug is authoritative: it is what lands on disk, it derives from the name
users see in the product, and it is the only value with a uniqueness guarantee
(allocateCollisionFreeSkillDir). A frontmatter `name` is author-supplied and two
imported skills may both claim the same one.

- modelVisibleSkills normalizes Name to the slug. All four model-visible
  listings (runtime brief + the three issue_context renderers) already route
  through it, so they cannot drift apart.
- ensureSkillFrontmatter rewrites `name` to the allocated slug and keeps every
  other key byte-identical, so deliberately shaped upstream frontmatter still
  survives. The rewrite follows the collision fallback slug too.
- Name matching is now top-level only. An indented `name:` belongs to a nested
  mapping; treating it as the skill's identity both missed that the block had no
  top-level name and would have spliced a top-level key into the nested one.

Known gap, tracked separately: the listings render the natural slug, so a
collision fallback to `<slug>-multica` still leaves the brief naming the user's
skill. Closing it needs the allocated slug threaded back from Prepare, which the
renderers cannot reach without giving up the byte-identical-brief guarantee.

Co-authored-by: multica-agent <github@multica.ai>

* fix(skills): cover multi-line name values and in-batch slug collisions (MUL-5529)

Two holes in the name-unification change, both found in review, both
reproduced before fixing.

1. setFrontmatterName replaced only the `name:` line, not the rest of the YAML
   value. A value may continue onto indented lines — `name: >-\n  upstream`,
   multi-line plain scalars, wrapped quoted scalars — so the continuation
   survived and YAML folded it into the new value: the block parsed as
   "my-slug upstream-name", not "my-slug". The directory == frontmatter-name
   invariant this change set exists to establish was still broken, just less
   visibly. frontmatterNameSpan now covers the whole value.

   As a side effect this also fixes `name:` with the value entirely on the
   following line, which previously read as "no name" and got a second
   top-level `name` injected above it — two `name` keys, which strict loaders
   reject outright.

2. The listings derived slugs with sanitizeSkillName alone, which is not
   injective: "A B" and "A-B" both reduce to "a-b". writeSkillFiles resolved
   that at write time, so the second skill landed in `a-b-multica` while both
   were listed as `a-b` — the second skill had no invocable name and the model
   was pointed at the first. This needed no user-installed skill and no
   local_directory; two such skills bound to one agent reproduce it in a clean
   workdir. resolveSkillSlugs now deduplicates the batch up front and both the
   listings and the writer derive from it, with skillSlugCandidate shared so
   the in-memory and filesystem allocators cannot disagree on the suffix
   sequence.

   Slugs are allocated over the unfiltered batch: hidden
   (disable-model-invocation) skills are still written to disk and still
   consume a slug, so filtering first would shift every later suffix.

Filesystem-dependent collisions against user-installed directories remain out
of scope and are still tracked in MUL-5550.

Regression tests assert the parsed YAML value rather than the output text —
the first line looked correct in every one of these cases — and set-equality
between listed names and the directories actually written. Both were confirmed
to fail against the previous implementation.

Co-authored-by: multica-agent <github@multica.ai>

* fix(skills): bound the frontmatter name value with the YAML parser (MUL-5529)

Review round two found a valid multi-line name the indentation rule still
mangled. A quoted scalar may wrap onto a line at the *same* indentation as its
key:

    name: "upstream
    continued"

The rule stopped at the key's line, stranding `continued"` and producing
invalid YAML. Probing the parser showed the same holds for flow collections
(`name: [a,\nb]`, `name: {a: 1,\nb: 2}`), so this was not a quoting special
case: indentation simply does not bound a YAML value, and no amount of
patching the heuristic would have made it one.

Value extent now comes from yaml.v3's own line numbers. frontmatterNameValueSpan
locates the `name` key node and ends the span where the next top-level key
begins, stepping back over blank lines and unindented comments so they survive
the rewrite. Unindented is the operative word: block scalar content is always
indented, so an unindented `#` can only be a comment, while `  # text` inside a
block scalar is value and stays in the span.

Detection stays lexical, in lexicalFrontmatterNameSpan. It runs on malformed
blocks too, where there is no parse to consult, and only decides which branch
to take.

setFrontmatterName now re-parses its own output and returns verified=false
unless `name` really is the slug; ensureSkillFrontmatter then routes to the
existing re-synthesis path rather than emitting a block it cannot vouch for.
This adds no new fallback — it feeds an already-present one. The invariant is
the whole point of the change, so an unprovable rewrite is worth less than a
reformatted block: with the span deliberately broken, output stays valid YAML
carrying the right name and loses only upstream formatting.

Tests extend the table to same-indent single/double quoted scalars, flow
sequences and mappings, and name-as-last-key, and now assert the *input* parses
so a case cannot pass by silently taking the re-synthesis path. Two more cover
comment survival and a `#` line inside a block scalar name. All four
same-indent cases fail against the previous implementation.

Co-authored-by: multica-agent <github@multica.ai>

* fix(skills): preserve policy keys when the surgical name rewrite fails (MUL-5529)

Review round three: the post-condition check added last round was routing valid
YAML into bare re-synthesis, and re-synthesis emits only name and description.

An anchor on the name value is one way to get there. Replacing the line drops
`&skill_name`, so `description: *skill_name` no longer resolves, the check
correctly rejects the rewrite — and the block was then rebuilt as just:

    name: my-slug

`disable-model-invocation: true` went with it. That key is the author's
instruction that a runtime must not surface the skill on its own, and the
SKILL.md we write is what native discovery reads, so losing it advertises a
skill that was deliberately hidden. Confirmed on the written output:
skillDisablesModelInvocation went from true to false. That is a semantic
regression, not the formatting loss the fallback was justified by.

The two failure modes are now separate:

  - invalid YAML → re-synthesize, unchanged; there is nothing to preserve.
  - valid YAML, rewrite unprovable → renameFrontmatterNameViaNode rebuilds the
    block from the parsed node with `name` set to the slug, keeping every other
    key. Formatting normalizes; semantics survive.

The anchor is deliberately kept on the name node. Dropping it is what
invalidates a document that aliases it; keeping it means the alias resolves to
the slug — that value changes, but the document still loads and every policy
key is intact. The rebuilt block is re-parsed and checked like the surgical
path, so an unprovable result still falls through to re-synthesis.

Regression test asserts name, disable-model-invocation, and a custom key all
survive, and that the written file still reads as hidden to
skillDisablesModelInvocation. It fails without the new path.

Co-authored-by: multica-agent <github@multica.ai>

* fix(skills): materialize aliases before renaming an anchored name (MUL-5529)

Round three kept the anchor on the name node so aliases would not dangle. That
avoided an invalid document but created a worse one: every alias resolves
through the anchor, so renaming the anchored value silently rewrote whatever
those fields meant.

    name: &shared "true"
    disable-model-invocation: *shared

skillDisablesModelInvocation went true -> false across the rewrite. Same
skill-exposing regression as round three, reached by keeping the key instead of
dropping it — which is the lesson: preserving a key is not the invariant,
preserving each key's resolved value is.

Aliases pointing at the name node are now materialized to the value they
resolved to *before* the rename, and the anchor is dropped afterwards as
unreferenced. Ordering matters: the clones are taken first, so they capture the
original value rather than the slug. Copies are per-alias, since sharing one
node would make the encoder re-emit an anchor/alias pair.

Nested aliases are covered by walking the whole document, not just the top
mapping.

Tests: three shapes (alias carrying the policy value, alias nested in another
mapping, two aliases of one anchor) each assert the fixture starts hidden and
stays hidden, that no anchor or alias survives, and that name is the slug. The
round-three test now also pins its aliased `description` to the pre-rename
value instead of merely tolerating the slug. All four fail without the change.

Co-authored-by: multica-agent <github@multica.ai>

* fix(skills): let the parser decide whether a name key exists (MUL-5529)

The branch that chooses between "rewrite the name" and "inject a name" was
still gated on a lexical scan, which only recognizes a bare `name:` carrying a
value on the same line. Two valid spellings therefore read as nameless:

    "name": upstream        # quoting is syntax, not identity
    name:                   # a key with no value is still the key

Both got a second `name` injected above the existing one, and a duplicate
mapping key is rejected outright — `mapping key "name" already defined`. The
skill does not end up misnamed, it fails to load. Single-quoted keys have the
same problem.

For valid YAML the parsed top-level mapping now answers the question, so any
spelling of the key routes to the rewrite. The lexical scan is confined to the
invalid-YAML branch, where there is no parse to consult and it is only choosing
between re-synthesis and injection. Nesting still reads as absent: a `name`
under another mapping is not the skill's name, so one is added.

Once past the gate the existing paths handle both shapes unchanged, since
frontmatterNameValueSpan already bounds the entry by node line numbers rather
than by how the key is written.

Also tightens the alias tests per review: the nested and two-alias cases now
assert `meta.inner` and `other` still resolve to the anchor's original value,
not merely that visibility survived. The invariant is every key's resolved
value, so every alias should be pinned, not just the one that gates hiding.

Co-authored-by: multica-agent <github@multica.ai>

* fix(skills): detect quoted and valueless name keys in malformed frontmatter (MUL-5529)

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Steve Jobs (Multica Agent) <agent-steve-jobs@multica.ai>
2026-07-30 21:21:13 +08:00

524 lines
19 KiB
Go

package execenv
import (
"strings"
"testing"
"gopkg.in/yaml.v3"
)
// parseFrontmatter is a strict YAML parse of a SKILL.md frontmatter block, used
// by the tests to assert the writer always emits something a strict runtime
// (e.g. Codex) can load. It mirrors how those runtimes read the file: take the
// text between the leading `---` and the next `---`, and yaml.Unmarshal it.
func parseFrontmatter(t *testing.T, content string) map[string]any {
t.Helper()
if !strings.HasPrefix(content, "---\n") {
t.Fatalf("content does not start with a frontmatter block:\n%s", content)
}
rest := content[len("---\n"):]
end := strings.Index(rest, "\n---")
if end < 0 {
t.Fatalf("frontmatter has no closing delimiter:\n%s", content)
}
var m map[string]any
if err := yaml.Unmarshal([]byte(rest[:end]), &m); err != nil {
t.Fatalf("frontmatter is not valid YAML: %v\nblock:\n%s", err, rest[:end])
}
return m
}
// TestEnsureSkillFrontmatterReSynthesizesInvalidYAML is the regression guard for
// the bug this change set fixes: a SKILL.md whose frontmatter has a `name` but
// is not valid YAML (an unquoted `: ` in the description is the canonical case)
// must be rewritten into a parseable block instead of being shipped as-is, or a
// strict runtime drops the whole skill on load.
//
// The name key's spelling must not change the route: `"name":`, `'name':`, and
// a valueless `name:` are the same top-level key as a bare `name: x`, so a
// block carrying any of them already has a name. Misreading them as nameless
// injects a second `name` above the malformed block — a duplicate mapping key,
// so the output stays unloadable instead of being healed (MUL-5529).
func TestEnsureSkillFrontmatterReSynthesizesInvalidYAML(t *testing.T) {
t.Parallel()
const body = "# Heading\n\nReal skill body.\n"
cases := []struct {
name string
nameLine string
}{
{name: "bare name key", nameLine: "name: keep-me"},
{name: "double quoted name key", nameLine: `"name": keep-me`},
{name: "single quoted name key", nameLine: `'name': keep-me`},
{name: "name key with no value", nameLine: "name:"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// `description: bad: value here` is the exact failure mode from
// the issue: the second `: ` makes YAML treat the tail as a
// nested mapping.
broken := "---\n" + tc.nameLine + "\ndescription: bad: value here\n---\n\n" + body
if isFrontmatterValidYAML(broken) {
t.Fatalf("fixture parses; it no longer exercises the malformed branch:\n%s", broken)
}
got := ensureSkillFrontmatter(broken, "my-slug", "DB description: with a colon")
// parseFrontmatter fails the test on output a strict runtime
// would reject — including the duplicate `name` this guards.
fm := parseFrontmatter(t, got)
if name, _ := fm["name"].(string); name != "my-slug" {
t.Errorf("name = %#v, want %q\noutput:\n%s", fm["name"], "my-slug", got)
}
if desc, _ := fm["description"].(string); desc != "DB description: with a colon" {
t.Errorf("description = %#v, want the DB description verbatim\noutput:\n%s", fm["description"], got)
}
if !strings.Contains(got, "Real skill body.") {
t.Errorf("body was dropped during re-synthesis:\n%s", got)
}
})
}
}
// When the existing frontmatter is invalid AND the DB description is empty, the
// writer still has to produce parseable YAML — just with name alone. (An empty
// description is dropped rather than emitted as `description: ""`.)
func TestEnsureSkillFrontmatterReSynthesizesInvalidYAMLWithEmptyDescription(t *testing.T) {
t.Parallel()
broken := "---\nname: keep-me\ndescription: bad: value\n---\n\nbody text\n"
got := ensureSkillFrontmatter(broken, "my-slug", "")
fm := parseFrontmatter(t, got)
if name, _ := fm["name"].(string); name != "my-slug" {
t.Errorf("name = %#v, want %q", fm["name"], "my-slug")
}
if _, present := fm["description"]; present {
t.Errorf("description should be omitted when DB description is empty, got %#v", fm["description"])
}
if !strings.Contains(got, "body text") {
t.Errorf("body was dropped during re-synthesis:\n%s", got)
}
}
// Valid frontmatter survives re-synthesis: every key keeps its upstream
// formatting and only `name` is retargeted at the slug. Re-synthesis proper is
// still reserved for the broken case.
//
// `name` cannot be left alone because runtimes disagree on which field
// identifies a skill (OpenCode: frontmatter `name`; Claude: directory name), so
// an upstream value that differs from the slug makes the skill answer to two
// names depending on the runtime (MUL-5529).
func TestEnsureSkillFrontmatterRetargetsNameAndKeepsOtherKeys(t *testing.T) {
t.Parallel()
valid := "---\nname: upstream\ndescription: \"colon: safe because quoted\"\nextra-key: kept\n---\n\nbody\n"
want := "---\nname: my-slug\ndescription: \"colon: safe because quoted\"\nextra-key: kept\n---\n\nbody\n"
if got := ensureSkillFrontmatter(valid, "my-slug", "ignored"); got != want {
t.Errorf("frontmatter name was not retargeted;\n got: %q\nwant: %q", got, want)
}
}
// A YAML value can span several lines. Replacing only the key's own line
// leaves the continuation behind, and YAML folds it into the new value:
// `name: >-\n upstream` became "my-slug upstream", so the directory name and
// the frontmatter name diverged again — exactly the invariant this rewrite
// exists to hold.
//
// These assert on the *parsed* value rather than the output text, because the
// text's first line looked correct in every one of these cases.
func TestEnsureSkillFrontmatterReplacesMultiLineNameValues(t *testing.T) {
t.Parallel()
cases := []struct {
name string
content string
}{
{
name: "folded block scalar",
content: "---\nname: >-\n upstream-name\ndescription: kept\n---\n\nbody\n",
},
{
name: "literal block scalar",
content: "---\nname: |-\n upstream-name\ndescription: kept\n---\n\nbody\n",
},
{
name: "multi-line plain scalar",
content: "---\nname: upstream\n continued\ndescription: kept\n---\n\nbody\n",
},
{
name: "wrapped quoted scalar",
content: "---\nname: \"upstream\n continued\"\ndescription: kept\n---\n\nbody\n",
},
{
name: "value entirely on the next line",
content: "---\nname:\n upstream-name\ndescription: kept\n---\n\nbody\n",
},
// A quoted scalar may wrap onto a line at the SAME indentation as its
// key, and so may a flow collection. Indentation therefore cannot bound
// a YAML value: an indentation rule stops at the key's line and strands
// the remainder, so the rewrite emitted invalid YAML rather than a
// wrong-but-parseable name.
{
name: "double quoted continued at same indent",
content: "---\nname: \"upstream\ncontinued\"\ndescription: kept\n---\n\nbody\n",
},
{
name: "single quoted continued at same indent",
content: "---\nname: 'upstream\ncontinued'\ndescription: kept\n---\n\nbody\n",
},
{
name: "flow sequence continued at same indent",
content: "---\nname: [a,\nb]\ndescription: kept\n---\n\nbody\n",
},
{
name: "flow mapping continued at same indent",
content: "---\nname: {a: 1,\nb: 2}\ndescription: kept\n---\n\nbody\n",
},
{
name: "name is the last key",
content: "---\ndescription: kept\nname: \"upstream\ncontinued\"\n---\n\nbody\n",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Assert the *input* is valid YAML first. Otherwise a case could
// pass for the wrong reason — ensureSkillFrontmatter re-synthesizes
// malformed blocks, which would produce the right name without ever
// exercising the surgical path these cases exist to cover.
parseFrontmatter(t, tc.content)
got := ensureSkillFrontmatter(tc.content, "my-slug", "")
fm := parseFrontmatter(t, got)
if name, _ := fm["name"].(string); name != "my-slug" {
t.Errorf("parsed name = %#v, want %q\noutput:\n%s", fm["name"], "my-slug", got)
}
// Neighbouring keys still survive the surgery.
if desc, _ := fm["description"].(string); desc != "kept" {
t.Errorf("description = %#v, want %q\noutput:\n%s", fm["description"], "kept", got)
}
if !strings.Contains(got, "body") {
t.Errorf("body was dropped:\n%s", got)
}
})
}
}
// An anchor on the name value defeats the surgical rewrite: replacing the line
// removes `&skill_name`, so an alias pointing at it no longer resolves and the
// post-condition check correctly rejects the result. What must NOT happen next
// is a drop to bare re-synthesis, which emits only name and description.
//
// The stakes are higher than lost formatting. `disable-model-invocation: true`
// is the author's instruction that a runtime must not surface this skill on its
// own; if the written SKILL.md loses it, native discovery advertises a skill
// that was deliberately hidden — the opposite of the author's intent.
func TestEnsureSkillFrontmatterKeepsPolicyKeysWhenSurgicalRewriteFails(t *testing.T) {
t.Parallel()
in := "---\nname: &skill_name upstream\ndescription: *skill_name\ndisable-model-invocation: true\ncustom-key: kept\n---\n\nbody\n"
// The input is valid YAML, so this exercises the fallback rather than the
// invalid-YAML re-synthesis path.
parseFrontmatter(t, in)
got := ensureSkillFrontmatter(in, "my-slug", "")
fm := parseFrontmatter(t, got)
if name, _ := fm["name"].(string); name != "my-slug" {
t.Errorf("name = %#v, want %q\noutput:\n%s", fm["name"], "my-slug", got)
}
if fm["disable-model-invocation"] != true {
t.Errorf("disable-model-invocation was dropped (%#v); the written skill would be exposed to native discovery\noutput:\n%s", fm["disable-model-invocation"], got)
}
if custom, _ := fm["custom-key"].(string); custom != "kept" {
t.Errorf("custom-key = %#v, want %q\noutput:\n%s", fm["custom-key"], "kept", got)
}
// The alias resolved to "upstream" before the rename and must still, rather
// than following name to the slug. See
// TestEnsureSkillFrontmatterKeepsAliasedValuesWhenNameIsRenamed for why
// letting it follow is a policy bypass and not just a cosmetic difference.
if desc, _ := fm["description"].(string); desc != "upstream" {
t.Errorf("aliased description = %#v, want %q\noutput:\n%s", fm["description"], "upstream", got)
}
// The written file must still read as hidden to the visibility check that
// gates whether a skill is advertised.
if !skillDisablesModelInvocation(got) {
t.Errorf("written skill no longer reads as disable-model-invocation:\n%s", got)
}
if !strings.Contains(got, "body") {
t.Errorf("body was dropped:\n%s", got)
}
}
// lookupPath resolves a dotted key path through nested frontmatter mappings,
// returning nil when any segment is missing.
func lookupPath(fm map[string]any, path string) any {
var cur any = fm
for _, seg := range strings.Split(path, ".") {
m, ok := cur.(map[string]any)
if !ok {
return nil
}
cur, ok = m[seg]
if !ok {
return nil
}
}
return cur
}
// Keeping the anchor alive is not enough: a document can express another
// setting by *reusing* the name's value, and then renaming the anchored node
// silently rewrites that setting too. Here `disable-model-invocation` is an
// alias of a name whose value is "true", so binding the alias to the renamed
// node turns the skill visible again — the same exposure as dropping the key,
// reached by a different route.
//
// Preserving a key is therefore not the invariant. Preserving each key's
// resolved *value* is.
func TestEnsureSkillFrontmatterKeepsAliasedValuesWhenNameIsRenamed(t *testing.T) {
t.Parallel()
cases := []struct {
name string
content string
// alsoAliased names further keys whose resolved value must still be
// the anchor's original "true" — asserting the invariant on every
// alias, not only the one that happens to gate visibility.
alsoAliased []string
}{
{
name: "alias carries the policy value",
content: "---\nname: &shared \"true\"\ndisable-model-invocation: *shared\ncustom-key: kept\n---\n\nbody\n",
},
{
name: "alias nested inside another mapping",
content: "---\nname: &shared \"true\"\nmeta:\n inner: *shared\ndisable-model-invocation: *shared\n---\n\nbody\n",
alsoAliased: []string{"meta.inner"},
},
{
name: "two aliases of the same anchor",
content: "---\nname: &shared \"true\"\ndisable-model-invocation: *shared\nother: *shared\n---\n\nbody\n",
alsoAliased: []string{"other"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Valid YAML in, so this is the node-rebuild fallback rather than
// the invalid-YAML re-synthesis path.
parseFrontmatter(t, tc.content)
// The skill starts out hidden from model invocation...
if !skillDisablesModelInvocation(tc.content) {
t.Fatalf("fixture is not hidden to begin with; the test proves nothing")
}
got := ensureSkillFrontmatter(tc.content, "my-slug", "")
// ...and must still be hidden afterwards.
if !skillDisablesModelInvocation(got) {
t.Errorf("rewrite exposed a hidden skill\noutput:\n%s", got)
}
fm := parseFrontmatter(t, got)
if name, _ := fm["name"].(string); name != "my-slug" {
t.Errorf("name = %#v, want %q\noutput:\n%s", fm["name"], "my-slug", got)
}
if _, stillAliased := fm["disable-model-invocation"]; !stillAliased {
t.Errorf("disable-model-invocation disappeared\noutput:\n%s", got)
}
// Every other alias of the same anchor must also hold its
// pre-rename value, not just the one gating visibility.
for _, path := range tc.alsoAliased {
if v := lookupPath(fm, path); v != "true" {
t.Errorf("aliased %s = %#v, want %q\noutput:\n%s", path, v, "true", got)
}
}
if strings.Contains(got, "*shared") || strings.Contains(got, "&shared") {
t.Errorf("anchor/alias survived instead of being materialized\noutput:\n%s", got)
}
})
}
}
// Whether a name exists is a question about the parsed document, not about how
// the key happens to be spelled. A lexical scan only recognizes a bare `name:`
// with a value on the same line, so a quoted key or an empty value read as
// "nameless" and got a second `name` injected above them.
//
// The result is a duplicate mapping key, which strict loaders reject outright:
// the skill does not merely carry the wrong name, it fails to load at all.
func TestEnsureSkillFrontmatterRecognizesQuotedAndEmptyNameKeys(t *testing.T) {
t.Parallel()
cases := []struct {
name string
content string
}{
{
name: "double quoted key",
content: "---\n\"name\": upstream\ndisable-model-invocation: true\ncustom-key: kept\n---\n\nbody\n",
},
{
name: "single quoted key",
content: "---\n'name': upstream\ndisable-model-invocation: true\ncustom-key: kept\n---\n\nbody\n",
},
{
name: "key present with no value",
content: "---\nname:\ndisable-model-invocation: true\ncustom-key: kept\n---\n\nbody\n",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
parseFrontmatter(t, tc.content)
got := ensureSkillFrontmatter(tc.content, "my-slug", "")
// parseFrontmatter fails the test on a duplicate `name`, which is
// the exact symptom this guards.
fm := parseFrontmatter(t, got)
if name, _ := fm["name"].(string); name != "my-slug" {
t.Errorf("name = %#v, want %q\noutput:\n%s", fm["name"], "my-slug", got)
}
if fm["disable-model-invocation"] != true {
t.Errorf("disable-model-invocation = %#v, want true\noutput:\n%s", fm["disable-model-invocation"], got)
}
if custom, _ := fm["custom-key"].(string); custom != "kept" {
t.Errorf("custom-key = %#v, want %q\noutput:\n%s", fm["custom-key"], "kept", got)
}
if !skillDisablesModelInvocation(got) {
t.Errorf("written skill no longer reads as hidden:\n%s", got)
}
})
}
}
// Comments and blank lines between the name entry and the next key are not part
// of the value, so the rewrite must step over them rather than absorb them into
// the replaced span.
func TestEnsureSkillFrontmatterKeepsCommentsAroundName(t *testing.T) {
t.Parallel()
in := "---\nname: >-\n upstream\n\n# keep this comment\ndescription: kept\n---\n\nbody\n"
want := "---\nname: my-slug\n\n# keep this comment\ndescription: kept\n---\n\nbody\n"
if got := ensureSkillFrontmatter(in, "my-slug", ""); got != want {
t.Errorf("comment or spacing was swallowed;\n got: %q\nwant: %q", got, want)
}
}
// A block scalar may legitimately contain a line that looks like a comment.
// Because block scalar content is always indented, the "step over comments"
// rule must only skip *unindented* ones — otherwise this content is left
// stranded outside the replaced span.
func TestEnsureSkillFrontmatterHandlesHashInsideBlockScalarName(t *testing.T) {
t.Parallel()
in := "---\nname: |-\n upstream\n # not a comment\ndescription: kept\n---\n\nbody\n"
got := ensureSkillFrontmatter(in, "my-slug", "")
fm := parseFrontmatter(t, got)
if name, _ := fm["name"].(string); name != "my-slug" {
t.Errorf("parsed name = %#v, want %q\noutput:\n%s", fm["name"], "my-slug", got)
}
if strings.Contains(got, "# not a comment") {
t.Errorf("block scalar content was stranded outside the replaced span:\n%s", got)
}
}
// The rewrite is byte-surgical: a CRLF block keeps its line endings instead of
// acquiring a lone LF on the name line.
func TestEnsureSkillFrontmatterPreservesCRLFOnNameRewrite(t *testing.T) {
t.Parallel()
valid := "---\r\nname: upstream\r\ndescription: kept\r\n---\r\n\r\nbody\r\n"
want := "---\r\nname: my-slug\r\ndescription: kept\r\n---\r\n\r\nbody\r\n"
if got := ensureSkillFrontmatter(valid, "my-slug", "ignored"); got != want {
t.Errorf("CRLF frontmatter mangled;\n got: %q\nwant: %q", got, want)
}
}
// frontmatterParts must agree on where a block ends regardless of how the
// closing delimiter is terminated. Before the shared helper, the validity check
// and the strip path used different close patterns (`\n---` vs `\n---\n`), so an
// EOF- or CRLF-terminated block was detected as present by one and missed by the
// other — leaving a stale block behind on re-synthesis.
func TestFrontmatterPartsClosingDelimiterVariants(t *testing.T) {
t.Parallel()
cases := []struct {
name string
content string
wantFM string
wantBody string
wantOK bool
}{
{
name: "newline terminated with blank line",
content: "---\nname: x\n---\n\nbody",
wantFM: "name: x",
wantBody: "\nbody",
wantOK: true,
},
{
name: "closing delimiter at EOF",
content: "---\nname: x\n---",
wantFM: "name: x",
wantBody: "",
wantOK: true,
},
{
name: "crlf terminated",
content: "---\r\nname: x\r\n---\r\nbody",
wantFM: "name: x\r",
wantBody: "body",
wantOK: true,
},
{
name: "horizontal rule is not a delimiter",
content: "---\nname: x\n----\n---\nbody",
wantFM: "name: x\n----",
wantBody: "body",
wantOK: true,
},
{
name: "no closing delimiter keeps full content as body",
content: "---\nname: x\nbody without close",
wantFM: "",
wantBody: "---\nname: x\nbody without close",
wantOK: false,
},
{
name: "no opening delimiter",
content: "no frontmatter here",
wantFM: "",
wantBody: "no frontmatter here",
wantOK: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fm, body, ok := frontmatterParts(tc.content)
if ok != tc.wantOK || fm != tc.wantFM || body != tc.wantBody {
t.Errorf("frontmatterParts(%q) = (%q, %q, %v), want (%q, %q, %v)",
tc.content, fm, body, ok, tc.wantFM, tc.wantBody, tc.wantOK)
}
})
}
}