diff --git a/cli/src/sandbox-layer.ts b/cli/src/sandbox-layer.ts index 905b60eaec..aa7e504753 100644 --- a/cli/src/sandbox-layer.ts +++ b/cli/src/sandbox-layer.ts @@ -12,6 +12,27 @@ export interface SkillFrontmatter { requiredCapabilities?: string[]; } +function stripSkillInlineComment(value: string): string { + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + const prev = value[i - 1]; + const afterSpace = i === 0 || prev === " " || prev === "\t"; + if ((ch === '"' || ch === "'") && (afterSpace || prev === "[" || prev === ",")) { + let close = i + 1; + while (close < value.length) { + if (ch === '"' && value[close] === "\\") close += 2; + else if (value[close] !== ch) close++; + else if (ch === "'" && value[close + 1] === "'") close += 2; + else break; + } + if (close < value.length) i = close; + } else if (ch === "#" && afterSpace) { + return value.slice(0, i).trimEnd(); + } + } + return value; +} + function stripSkillQuotes(value: string): string { const trimmed = value.trim(); if ( @@ -47,7 +68,7 @@ export function parseSkillFrontmatter(md: string, sourcePath: string): SkillFron const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line); if (!kv) continue; const key = kv[1]!; - const rest = kv[2] ?? ""; + const rest = stripSkillInlineComment(kv[2] ?? ""); if (rest === ">" || rest === "|" || rest === ">-" || rest === "|-") { const literal = rest[0] === "|"; const baseIndent = skillIndent(line); @@ -75,10 +96,10 @@ export function parseSkillFrontmatter(md: string, sourcePath: string): SkillFron if (!next.trim() || skillIndent(next) <= baseIndent) break; const item = /^\s*-\s+(.*)$/.exec(next); if (!item) break; - items.push(stripSkillQuotes(item[1]!)); + items.push(stripSkillQuotes(stripSkillInlineComment(item[1]!))); i++; } - fields[key] = items; + fields[key] = items.filter(Boolean); } const name = fields["name"]; diff --git a/cli/test/tool-descriptor.test.ts b/cli/test/tool-descriptor.test.ts index 6d91bd6afa..98be227a89 100644 --- a/cli/test/tool-descriptor.test.ts +++ b/cli/test/tool-descriptor.test.ts @@ -257,6 +257,21 @@ test("parseSkillFrontmatter requires name + description + body; requiredCapabili "SKILL.md", ); assert.deepEqual(caps.requiredCapabilities, ["a", "b"]); + const commented = parseSkillFrontmatter( + '---\nname: demo # note\ndescription: "a # inside quotes" # note\ntag: c#sharp\nrequiredCapabilities: [a, b] # why\n---\nbody', + "SKILL.md", + ); + assert.equal(commented.name, "demo"); + assert.equal(commented.description, "a # inside quotes"); + assert.deepEqual(commented.requiredCapabilities, ["a", "b"]); + assert.throws( + () => parseSkillFrontmatter("---\nname: # note\ndescription: y\n---\nbody", "SKILL.md"), + /missing "name"/, + ); + const long = `a${" ".repeat(200_000)}${'\\"'.repeat(100_000)}b`; + const started = performance.now(); + assert.equal(parseSkillFrontmatter(`---\nname: x\ndescription: "${long}"\n---\nbody`, "SKILL.md").description, long); + assert.ok(performance.now() - started < 2_000, "parseSkillFrontmatter must not backtrack on long scalars"); assert.throws(() => parseSkillFrontmatter("no frontmatter", "SKILL.md"), /missing YAML frontmatter/); assert.throws(() => parseSkillFrontmatter("---\ndescription: y\n---\nbody", "SKILL.md"), /missing "name"/); assert.throws(() => parseSkillFrontmatter("---\nname: x\n---\nbody", "SKILL.md"), /missing "description"/); @@ -278,6 +293,17 @@ test("drift-lock: cli skill parser matches the core seed parser's output", () => "---\nname: a\ndescription: b\n---\nbody\n", "\uFEFF---\r\nname: a\r\ndescription: b\r\n---\r\nbody\r\n", "---\nname: a\ndescription: b\nrequiredCapabilities: [one, 'two']\n---\nbody\n", + "---\nname: a # inline note\ndescription: b # inline note\n---\nbody\n", + "---\nname: a\ndescription: b\nrequiredCapabilities: [one, two] # why\n---\nbody\n", + "---\nname: a\ndescription: > # note\n folded\n---\nbody\n", + "---\nname: a\ndescription: b\nrequiredCapabilities:\n - one # why\n---\nbody\n", + "---\nname: # note\ndescription: b\n---\nbody\n", + "---\nname: a\ndescription: 'b # c'\n---\nbody\n", + "---\nname: a\ndescription: b#c\n---\nbody\n", + '---\nname: a\ndescription: "He said \\"go\\" # really"\n---\nbody\n', + "---\nname: a\ndescription: 'it''s # fine'\n---\nbody\n", + "---\nname: a\ndescription: use 'em all # note\n---\nbody\n", + "---\nname: a\ndescription: b\nrequiredCapabilities:\n - one # why\n - # dropped\n - two\n---\nbody\n", "---\nname: a\ndescription: b\nrequiredCapabilities:\n - one\n - two\n---\nbody\n", "---\nname: a\ndescription: >\n folded description\n---\nbody\n", "---\nname: a\ndescription: b\nrequiredCapabilities: one, two\n---\nbody\n", diff --git a/src/skills/frontmatter.ts b/src/skills/frontmatter.ts index e4b1742b84..baf1b92259 100644 --- a/src/skills/frontmatter.ts +++ b/src/skills/frontmatter.ts @@ -5,6 +5,27 @@ export interface Frontmatter { body: string; } +function stripInlineComment(value: string): string { + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + const prev = value[i - 1]; + const afterSpace = i === 0 || prev === " " || prev === "\t"; + if ((ch === '"' || ch === "'") && (afterSpace || prev === "[" || prev === ",")) { + let close = i + 1; + while (close < value.length) { + if (ch === '"' && value[close] === "\\") close += 2; + else if (value[close] !== ch) close++; + else if (ch === "'" && value[close + 1] === "'") close += 2; + else break; + } + if (close < value.length) i = close; + } else if (ch === "#" && afterSpace) { + return value.slice(0, i).trimEnd(); + } + } + return value; +} + function stripQuotes(value: string): string { const t = value.trim(); if (t.length >= 2 && ((t[0] === '"' && t.at(-1) === '"') || (t[0] === "'" && t.at(-1) === "'"))) { @@ -44,7 +65,7 @@ export function parseFrontmatter(raw: string): Frontmatter { const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line); if (!m) continue; const key = m[1]!; - const rest = m[2] ?? ""; + const rest = stripInlineComment(m[2] ?? ""); if (rest === ">" || rest === "|" || rest === ">-" || rest === "|-") { const literal = rest[0] === "|"; @@ -74,10 +95,10 @@ export function parseFrontmatter(raw: string): Frontmatter { if (indentOf(next) <= baseIndent) break; const item = /^\s*-\s+(.*)$/.exec(next); if (!item) break; - items.push(stripQuotes(item[1]!)); + items.push(stripQuotes(stripInlineComment(item[1]!))); i++; } - attrs[key] = items; + attrs[key] = items.filter((s) => s.length > 0); } return { attrs, body: source.slice(end + closing[0].length).replace(/^\s+/, "") }; diff --git a/test/frontmatter.test.ts b/test/frontmatter.test.ts index 9eccf0afe7..401994793e 100644 --- a/test/frontmatter.test.ts +++ b/test/frontmatter.test.ts @@ -97,3 +97,57 @@ test("seed skill names cannot escape or alias their materialized directory", () } assert.equal(parseSeedSkillFrontmatter(skill("foo-bar_v1.2")).name, "foo-bar_v1.2"); }); + +test("drops unquoted inline comments but keeps # inside quotes or words", () => { + const { attrs } = parseFrontmatter(`--- +name: demo # human-readable note +description: "a # inside quotes" # trailing note +tag: c#sharp +joined: foo# bar +caps: [one, two] # why +folded: > # note + folded body +empty: # nothing left +list: + - command:demo # why +--- +body`); + assert.equal(attrs.name, "demo"); + assert.equal(attrs.description, "a # inside quotes"); + assert.equal(attrs.tag, "c#sharp"); + assert.equal(attrs.joined, "foo# bar"); + assert.deepEqual(attrs.caps, ["one", "two"]); + assert.equal(attrs.folded, "folded body"); + assert.deepEqual(attrs.empty, []); + assert.deepEqual(attrs.list, ["command:demo"]); +}); + +test("a commented-out value is missing, not a literal comment", () => { + assert.throws(() => parseSeedSkillFrontmatter("---\nname: # note\ndescription: d\n---\nbody"), /requires name/); + assert.equal(parseSeedSkillFrontmatter("---\nname: demo # note\ndescription: d # note\n---\nbody").name, "demo"); +}); + +test("a # inside quotes (with escapes, doubling, or an unclosed quote) is not a comment", () => { + const { attrs } = parseFrontmatter(`--- +escaped: "He said \\"go\\" # really" +doubled: 'it''s # fine' +unclosed: use 'em all # note +caps: + - one # why + - # dropped + - two +--- +body`); + assert.equal(attrs.escaped, 'He said \\"go\\" # really'); + assert.equal(attrs.doubled, "it''s # fine"); + assert.equal(attrs.unclosed, "use 'em all"); + assert.deepEqual(attrs.caps, ["one", "two"]); +}); + +test("inline comment stripping stays linear on long scalars", () => { + const value = `a${" ".repeat(200_000)}${'\\"'.repeat(100_000)}b`; + const started = performance.now(); + const { attrs } = parseFrontmatter(`---\nname: demo\ndescription: "${value}"\n---\nbody`); + assert.ok(performance.now() - started < 2_000, "parseFrontmatter must not backtrack on long scalars"); + assert.equal(attrs.description, value); +});