-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(skills): use full YAML parser for frontmatter to support block scalars #4870
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1fcaa50
6ddda28
fab586e
1b307d0
3d9e270
1a734c1
9cdc128
bad7f19
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { describe, expect, it } from 'vitest'; | ||
| import { parseSkillContent } from './skill-load.js'; | ||
|
|
||
| describe('parseSkillContent with real YAML parser', () => { | ||
| const testPath = '/test/extension/skills/test-skill/SKILL.md'; | ||
|
|
||
| it('parses folded block scalar descriptions (>)', () => { | ||
| const markdown = `--- | ||
| name: test-skill | ||
| description: > | ||
| This is a folded | ||
| multiline description. | ||
| --- | ||
|
|
||
| Body. | ||
| `; | ||
| const config = parseSkillContent(markdown, testPath); | ||
| expect(config.name).toBe('test-skill'); | ||
| expect(config.description).toBe( | ||
| 'This is a folded multiline description.\n', | ||
| ); | ||
| }); | ||
|
|
||
| it('parses literal block scalar descriptions (|)', () => { | ||
| const markdown = `--- | ||
| name: test-skill | ||
| description: | | ||
| Line one. | ||
| Line two. | ||
| --- | ||
| Body. | ||
| `; | ||
| const config = parseSkillContent(markdown, testPath); | ||
| expect(config.description).toBe('Line one.\nLine two.\n'); | ||
| }); | ||
|
|
||
| it('parses strip-chomped folded block scalar (>-)', () => { | ||
| const markdown = `--- | ||
| name: test-skill | ||
| description: >- | ||
| No trailing newline. | ||
| --- | ||
| Body. | ||
| `; | ||
| const config = parseSkillContent(markdown, testPath); | ||
| expect(config.description).toBe('No trailing newline.'); | ||
| }); | ||
|
|
||
| it('does not coerce date-like values to Date objects', () => { | ||
| const markdown = `--- | ||
| name: test-skill | ||
| description: A skill created on 2024-01-01 | ||
| --- | ||
| Body. | ||
| `; | ||
| const config = parseSkillContent(markdown, testPath); | ||
| expect(typeof config.description).toBe('string'); | ||
| }); | ||
|
|
||
| it('handles allowedTools array correctly', () => { | ||
| const markdown = `--- | ||
| name: test-skill | ||
| description: A test skill | ||
| allowedTools: | ||
| - read_file | ||
| - write_file | ||
| --- | ||
| Body. | ||
| `; | ||
| const config = parseSkillContent(markdown, testPath); | ||
| expect(config.allowedTools).toEqual(['read_file', 'write_file']); | ||
| }); | ||
|
|
||
| it('handles complex frontmatter with mixed field types', () => { | ||
| const markdown = `--- | ||
| name: test-skill | ||
| description: > | ||
| Manage the full lifecycle of | ||
| cloud resources. | ||
| allowedTools: | ||
| - read_file | ||
| - write_file | ||
| model: qwen-max | ||
| argument-hint: "[resource-type]" | ||
| priority: 10 | ||
| disable-model-invocation: true | ||
| --- | ||
| Body content here. | ||
| `; | ||
| const config = parseSkillContent(markdown, testPath); | ||
| expect(config.name).toBe('test-skill'); | ||
| expect(config.description).toContain('Manage the full lifecycle'); | ||
| expect(config.allowedTools).toEqual(['read_file', 'write_file']); | ||
| expect(config.model).toBe('qwen-max'); | ||
| expect(config.argumentHint).toBe('[resource-type]'); | ||
| expect(config.priority).toBe(10); | ||
| expect(config.disableModelInvocation).toBe(true); | ||
| }); | ||
|
|
||
| it('falls back gracefully for malformed YAML', () => { | ||
| // Unclosed flow mapping triggers a yaml.parse error; the simple | ||
| // parser ignores it and still extracts name + description. | ||
| const markdown = `--- | ||
| name: test-skill | ||
| description: a test skill | ||
| extra: {key: [nested unclosed | ||
| --- | ||
| Body. | ||
| `; | ||
| const config = parseSkillContent(markdown, testPath); | ||
| expect(config.name).toBe('test-skill'); | ||
| expect(config.description).toBe('a test skill'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,6 +36,124 @@ describe('yaml-parser', () => { | |
| }, | ||
| }); | ||
| }); | ||
|
|
||
| it('should parse YAML folded block scalar (>)', () => { | ||
| const input = | ||
| 'name: test-skill\ndescription: >\n This is a folded\n multiline description.'; | ||
| const result = parse(input); | ||
| expect(result['name']).toBe('test-skill'); | ||
| expect(result['description']).toBe( | ||
| 'This is a folded multiline description.\n', | ||
| ); | ||
| }); | ||
|
|
||
| it('should parse YAML literal block scalar (|)', () => { | ||
| const input = | ||
| 'name: test-skill\ndescription: |\n Line one.\n Line two.'; | ||
| const result = parse(input); | ||
| expect(result['name']).toBe('test-skill'); | ||
| expect(result['description']).toBe('Line one.\nLine two.\n'); | ||
| }); | ||
|
|
||
| it('should parse YAML block scalar with strip chomping (>-)', () => { | ||
| const input = | ||
| 'name: test-skill\ndescription: >-\n Folded without trailing newline.'; | ||
| const result = parse(input); | ||
| expect(result['name']).toBe('test-skill'); | ||
| expect(result['description']).toBe('Folded without trailing newline.'); | ||
| }); | ||
|
|
||
| it('should not coerce date-like strings into Date objects', () => { | ||
| const input = 'name: test\ncreated: 2024-01-01'; | ||
| const result = parse(input); | ||
| expect(typeof result['created']).toBe('string'); | ||
| expect(result['created']).toBe('2024-01-01'); | ||
| }); | ||
|
|
||
| it('should strip bare keys with no value', () => { | ||
| const input = 'name: test\nhooks:'; | ||
| const result = parse(input); | ||
| expect(result['name']).toBe('test'); | ||
| expect(result['hooks']).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('should strip explicit null and tilde values', () => { | ||
| const input = 'a: null\nb: ~'; | ||
| const result = parse(input); | ||
| expect(result['a']).toBeUndefined(); | ||
| expect(result['b']).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('should treat yes/no as strings in YAML 1.2 core schema', () => { | ||
| const input = 'answer: yes\nother: no'; | ||
| const result = parse(input); | ||
| expect(result['answer']).toBe('yes'); | ||
| expect(result['other']).toBe('no'); | ||
| }); | ||
|
|
||
| it('should fall back to simple parser on invalid YAML', () => { | ||
| // Unclosed flow sequence triggers a yaml.parse error | ||
| const input = 'name: test\nallowedTools: [unclosed'; | ||
| const result = parse(input); | ||
| expect(result['name']).toBe('test'); | ||
| }); | ||
|
|
||
| it('should strip null values in fallback path same as main path', () => { | ||
| // Unclosed flow forces fallback to parseSimple; explicit null | ||
| // must be stripped so callers can use `!== undefined` consistently. | ||
| const input = 'name: test\noptional: null\nbroken: [unclosed'; | ||
| const result = parse(input); | ||
| expect(result['name']).toBe('test'); | ||
| expect(result['optional']).toBeUndefined(); | ||
| expect('optional' in result).toBe(false); | ||
| }); | ||
|
|
||
| it('should not allow prototype pollution via simple parser fallback', () => { | ||
| // Crafted to fail yaml.parse (unclosed flow) and trigger parseSimple, | ||
| // where __proto__ as a nested-object key could pollute the prototype. | ||
| const input = | ||
| '__proto__:\n polluted: true\nname: test\nbroken: [unclosed'; | ||
| const result = parse(input); | ||
| expect(result['name']).toBe('test'); | ||
| const clean: Record<string, unknown> = {}; | ||
| expect(clean['polluted']).toBeUndefined(); | ||
| expect(Object.getPrototypeOf(result)).toBeNull(); | ||
| }); | ||
|
|
||
| it('should handle empty input gracefully', () => { | ||
| const result = parse(''); | ||
| expect(result).toEqual({}); | ||
| }); | ||
|
|
||
| it('should handle comment-only input gracefully', () => { | ||
| const result = parse('# just a comment'); | ||
| expect(result).toEqual({}); | ||
| }); | ||
|
|
||
| it('should not allow prototype pollution via __proto__ key', () => { | ||
| const input = 'name: legit\n__proto__:\n polluted: true'; | ||
| const result = parse(input); | ||
| expect(result['name']).toBe('legit'); | ||
| // result uses null prototype — __proto__ is a plain own property | ||
| expect(Object.getPrototypeOf(result)).toBeNull(); | ||
| expect(Object.hasOwn(result, '__proto__')).toBe(true); | ||
| }); | ||
|
|
||
| it('should not resolve !!timestamp explicit tags', () => { | ||
| const input = 'name: test\ncreated: !!timestamp 2024-01-01'; | ||
| const result = parse(input); | ||
| expect(typeof result['created']).toBe('string'); | ||
| }); | ||
|
|
||
| it('should sanitize nested objects recursively', () => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The PR description calls out "Date/Uint8Array coercion guards" but only it('should not resolve !!binary explicit tags', () => {
const input = 'name: test\ndata: !!binary SGVsbG8=';
const result = parse(input);
expect(typeof result['data']).toBe('string');
});Without this, the Uint8Array branch in — qwen3.7-plus via Qwen Code /review |
||
| const input = | ||
| 'name: test\nmetadata:\n created: !!timestamp 2024-01-01\n note: hello'; | ||
| const result = parse(input); | ||
| const metadata = result['metadata'] as Record<string, unknown>; | ||
| expect(typeof metadata['created']).toBe('string'); | ||
| expect(metadata['note']).toBe('hello'); | ||
| expect(Object.getPrototypeOf(metadata)).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('stringify', () => { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.