Skip to Content
ДизайнЗамена YAML-парсера — результаты исследования

Замена YAML-парсера — результаты исследования

Внутренний проектный документ по замене самописного 192-строчного YAML-парсера в packages/core/src/utils/yaml-parser.ts на полноценную библиотеку, чтобы отложенные поля mcpServers и hooks из схемы declarative-агента Claude Code могли без потерь проходить полный цикл обработки в коде subagent / skill / converter.

Дополнение к docs/design/declarative-agents-port.md. Задача: #4821 . Требуется для реализации follow-up к PR #4842 .

Фаза 0 — Проверенные источники

ИсточникВерсия / ДатаПочему считается авторитетным
~/code/claude-code/src/utils/yaml.tsстарый снэпшот CC (до 2.1.168)прямой исходник — 15-строчная обертка, указывающая библиотеку
~/code/claude-code/src/utils/frontmatterParser.tsтот же снэпшотпрямой исходник — 370-строчный разделитель frontmatter + восстановление в 2 прохода
/private/tmp/cc-2.1.168/claude.stringsизвлечено из CC 2.1.168авторитетный источник для текущего поведения — строки содержат обфусцированные имена символов, но включают JSON-схему и текст сообщений об ошибках
packages/core/src/utils/yaml-parser.ts (этот репо)HEAD ветки lazzy/gifted-hamilton-684741заменяемый парсер
live node -e пробы против yaml@2.8.1 в этом дереве2026-06-08эмпирическое поведение безопасности — якоря, merge keys, !!js/function, billion-laughs, maxAliasCount (результаты встроены в Фазу 4)

Метки уверенности: C подтверждено прямыми доказательствами; I выведено из нескольких подтвержденных фактов; O открытый вопрос.

Фаза 1 — Какую YAML-библиотеку использует CC?

Ответ: yaml (eemeli/yaml), а НЕ js-yaml. Подтверждено дословным чтением ~/code/claude-code/src/utils/yaml.ts:

export function parseYaml(input: string): unknown { if (typeof Bun !== 'undefined') { return Bun.YAML.parse(input); } // eslint-disable-next-line @typescript-eslint/no-require-imports return (require('yaml') as typeof import('yaml')).parse(input); }
  • Библиотека: npm-пакет yaml. C
  • API: метод верхнего уровня .parse(input). Использует схему по умолчанию пакета (которая представляет собой YAML 1.2 core — надмножество JSON, без JS-расширений). C
  • Быстрый путь для Bun: при запуске под Bun, CC использует Bun.YAML.parse(), чтобы избежать бандлинга ~270 КБ YAML-парсера. C Не относится к qwen-code (мы не таргетируем рантайм Bun).
  • Режим схемы: ЯВНО не установлен нигде в CC. Полагается на поведение yaml по умолчанию, а также на валидацию zod на уровне потребителя (DL7, gS8, TKO/_u согласно docs/design/declarative-agents-port.md). C

Почему yaml, а не js-yaml

Характеристикаjs-yaml 4.xyaml (eemeli) 2.x
Схема по умолчаниюDEFAULT_SAFE_SCHEMA (начиная с 4.x) — безопасная; старые версии имели DEFAULT_FULL_SCHEMA с JScore (спецификация YAML 1.2) — только JSON-типы
Тег !!js/functionНЕ поддерживается в 4.x (был в 3.x)Никогда не поддерживался
Защита от billion-laughsОтсутствует (ручная ответственность)Встроенный maxAliasCount: 100 по умолчанию
Merge keys (<<)Поддерживаются (нужно отключать через MERGE_SCHEMA или фильтрацию)Отключены по умолчанию, включаются через { merge: true }
Уже есть в зависимостях qwen-code?js-yaml@4.1.1yaml@2.8.1 ✓ (уже импортируется в skill-manager)

Оба варианта являются разумным выбором в 2026 году, но в исходном техническом задании рекомендовалось использовать FAILSAFE_SCHEMA / CORE_SCHEMA из js-yaml. Мы отходим от этих рекомендаций по трем конкретным причинам:

  1. Паритет с CC. Весь смысл портирования схемы frontmatter из CC заключается в том, чтобы пользователи могли просто поместить файл агента CC в .qwen/agents/, и он парсился идентичным образом. Использование того же парсера, что и в CC, минимизирует расхождения в граничных YAML-конструкциях (multi-doc streams, flow vs block scalars, обработка тегов).
  2. yaml уже используется напрямую в skill-manager.ts — см. packages/core/src/skills/skill-manager.ts:13 (import * as yaml from 'yaml'). Стандартизация на yaml устраняет один из двух дублирующихся YAML-стеков в одном пакете. C (результат grep задокументирован в Фазе 6).
  3. Более безопасные значения по умолчанию, чем в js-yaml. Встроенный maxAliasCount в yaml блокирует billion-laughs без ручной настройки; merge keys отключены по умолчанию; произвольные теги становятся литеральными строками с YAMLWarning, а не вызывают вызываемые резолверы. Эмпирические данные приведены в Фазе 4.

Если будущий мейнтейнер захочет убрать зависимость от yaml и унифицировать всё на js-yaml, миграция будет механической: замените yaml.parse / yaml.stringify на jsYaml.load(s, { schema: jsYaml.CORE_SCHEMA }) / jsYaml.dump. Обе библиотеки выдают одинаковый результат для тех 100% подмножества, которое фактически используют CC и qwen-code (пары ключ-значение, списки, вложенные словари, скалярные булевы значения и числа). Если этот вопрос возникнет, зафиксируйте это решение отдельно.

Фаза 2 — Пайплайн парсинга frontmatter (CC)

Файл ~/code/claude-code/src/utils/frontmatterParser.ts содержит 370 строк. Ключевые находки:

ШагЛогикаИсточник
Поиск разделителяRegex /^---\s*\n([\s\S]*?)\n---\s*\n?/ — открывается на колонке 0, тело нежадное, закрывающий --- должен быть на отдельной строкеfrontmatterParser.ts:~123 (номера строк из старого снэпшота; считать приблизительными) C
Парсинг (проход 1)Вызов parseYaml(body). При успехе → вернуть распарсенный объект + остаток контента.тот же файл, начало try-блока C
Восстановление (проход 2)При YAMLException, обходить строки, автоматически заключать в кавычки значения, похожие на даты/двоеточия/спецсимволы, повторить parseYaml один раз.строки ~85–121 в старом снэпшоте C (нормализация tab → 2 spaces, эвристика ISO-дат, ловушка с двоеточием)
Обработка ошибки (fallthrough)Оба прохода не удались → логировать через logForDebugging, вернуть { data: {}, content: text }. Агент загружается с пустым frontmatter.конец функции C
ТелеметрияОбернуто дальше по стеку — события tengu_frontmatter_shadow_unknown_key / _mismatch срабатывают из ug5.agent (схема Ig5)claude.strings:308120, 309074, 309076 (перекрестные ссылки в Фазе 1 docs/design/declarative-agents-port.md)

Следствие для qwen-code: нам НЕ нужно клонировать восстановление в 2 прохода. subagent-manager.ts в qwen-code уже применяет более строгую семантику “выбрасывать исключение при некорректном frontmatter на верхнем уровне” для своего загрузчика (см. parseSubagentContent), а восстановление в 2 прохода специально реализовано для прощения ошибок в старых, отредактированных вручную файлах агентов CC. Перенос более строгого подхода вполне допустим; нам просто нужно не ронять весь загрузчик, если вложенные поля некорректны. См. Фазу 5 для подхода warn-and-drop.

Фаза 3 — Вложенная валидация через zod (CC)

Релевантные валидаторы CC согласно Фазе 1 docs/design/declarative-agents-port.md + перекрестная проверка бинарных строк:

mcpServers (символ CC gS8 / JSON-shadow jL7)

mcpServers: z.union([ z.string(), // ссылка на имя сервера z.record(z.string(), McpServerConfigSchema()), // инлайн { name: spec } ])

McpServerConfigSchema() (из ссылки claude.strings:124–135) представляет собой дискриминированное объединение по полю type:

typeОбязательные поляПримечания
"stdio"command: string, args?: string[]Плюс env?: Record<string,string>, cwd?: string
"sse"url: stringПлюс headers?: Record<string,string>
"http"url: stringПлюс headers?, method?
"websocket"url: stringпаритет qwen-code неизвестен — отложить до необходимости
"sdk"варьируетсяВнутреннее использование CC; нам НЕ нужно поддерживать
"claudeai-proxy"варьируетсяВнутреннее использование CC; нам НЕ нужно поддерживать

Для qwen-code v1: валидировать как Record<string, unknown> (мягкий стиль DL7) и позволить последующему слиянию в Config.getMcpServers() выполнить приведение формы. В qwen-code уже есть класс MCPServerConfig с дискриминацией по type — мы переиспользуем этот конвертер вместо дублирования zod-схемы. См. Фазу 4 плана runtime-wiring в docs/design/declarative-agents-port.md.

hooks (символ CC TKO / _u)

hooks: Partial<Record<HookEvent, HookMatcher[]>> HookMatcher: { matcher?: string, hooks: HookConfig[] } HookConfig (дискриминированное объединение по `type`): - { type: 'command', command: string, timeout?: number, ... } - { type: 'prompt', prompt: string, ... } - { type: 'agent', agent: string, ... } - { type: 'http', url: string, headers?, ... }

Согласно перекрестной проверке строк, ключи событий хуков представляют собой тот же набор, который уже поддерживает qwen-code: PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, SessionEnd, Stop, SubagentStart, SubagentStop, Notification — плюс несколько событий, специфичных только для qwen-code (TodoCreated, TodoCompleted), которых нет в CC.

Для qwen-code v1: валидировать как Record<string, unknown> (мягко), а затем передавать существующим валидаторам SessionHooksManager из qwen-code, которые уже реализуют форму HookDefinition[] для каждого события (см. packages/core/src/hooks/types.ts:207–211 согласно runtime-mapping из Фазы 1).

Почему оба валидатора используют z.unknown() на уровне shadow-схемы Ig5

Ig5 — это телеметрическая shadow-схема — она генерирует события tengu_frontmatter_shadow_unknown_key, если YAML-ключ отсутствует в известном наборе, и события _mismatch, если известный ключ имеет неправильный тип. Она намеренно использует z.unknown() для mcpServers и hooks, потому что Ig5 выполняется на этапе ПАРСИНГА и выдавала бы ложные события mismatch для каждой inline-спецификации mcpServers. Реальная валидация делегирована:

  • gS8 (для mcpServers) — вызывается во время регистрации агента из DL7 через safeParse для каждого элемента
  • TKO (для hooks) — вызывается во время срабатывания хука из _u().safeParse Эта ленивая валидация — тот самый подход, который должна перенять qwen-code: сделать парсер frontmatter максимально терпимым (аналог z.unknown() в TS), а валидацию выполнять в месте использования. Попытка перенести полное дерево zod в SubagentConfig заставила бы нас также импортировать класс MCPServerConfig и тип HookDefinition из qwen в слой, где они сейчас не находятся, и потребовала бы создания фиктивных валидаторов для type: 'sdk' / type: 'claudeai-proxy', которые мы на самом деле не поддерживаем.

Фаза 4 — Безопасность

Эмпирическая проверка настроек по умолчанию yaml@2.8.1 в дереве qwen-code:

Результаты проверки

$ node -e "const y=require('yaml'); console.log(y.parse('a: 1').constructor.name, y.parseDocument('a: 1').schema?.name)" Object core

→ схема по умолчанию — 'core' (надмножество JSON из YAML 1.2). C

$ node -e "const y=require('yaml'); console.log(y.parse('!!js/function \"function(){}\"'))" function(){} (node:18525) [TAG_RESOLVE_FAILED] YAMLWarning: Unresolved tag: tag:yaml.org,2002:js/function

→ тег !!js/function НЕ выполняется. Значение преобразуется в буквальную строку "function(){}" (не в вызываемый объект функции) и выдает неустранимое предупреждение YAMLWarning. Злоумышленник не может достичь RCE через этот вектор. C

$ node -e "const y=require('yaml'); const bomb = 'a: &a [hi,hi]\nb: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a,*a]\nc: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b,*b]\nd: [*c,*c,*c,*c,*c,*c,*c,*c,*c,*c]'; try { y.parse(bomb) } catch(e){ console.log('REJECTED:', e.message) }" REJECTED: Excessive alias count indicates a resource exhaustion attack

→ расширение псевдонимов / billion-laughs ОТКЛОНЯЕТСЯ по умолчанию. Библиотека поставляется с maxAliasCount: 100 (неудачный парсинг подсчитывает 1+10+100 = 111 псевдонимов). C

$ node -e "const y=require('yaml'); console.log(JSON.stringify(y.parse('defaults: &d\n a: 1\nfoo:\n <<: *d\n b: 2')))" {"defaults":{"a":1},"foo":{"<<":{"a":1},"b":2}}

→ ключ слияния (<<) по умолчанию парсится как буквальная строка ключа, а НЕ раскрывается. Парсер << включается опционально через { merge: true }. Мы НЕ будем его включать. C

$ node -e "const y=require('yaml'); const yml='mcpServers:\n filesystem:\n type: stdio\n command: node\n args:\n - /path/to/server.js'; console.log(JSON.stringify(y.parse(yml), null, 2))" { "mcpServers": { "filesystem": { "type": "stdio", "command": "node", "args": ["/path/to/server.js"] } } }

→ вложенные mcpServers в формате CC корректно парсятся в глубоко вложенный объект/массив. C

Сводка по безопасности

ВекторПоведение yaml@2.8.1 по умолчаниюНеобходимые действия в qwen-code
Выполнение произвольного JSНевозможно — нет evalНет
Тег !!js/functionПреобразуется в буквальную строку + предупреждениеНет
Billion laughsОтклоняется (maxAliasCount: 100)Нет — оставить по умолчанию
Ключи слияния (<<)Обрабатываются как буквальный ключНет — оставить по умолчанию (НЕ передавать merge: true)
Якоря / псевдонимы (обычное использование)Разрешены, полезны для данных в формате CCНет
Произвольные неизвестные тегиСтрока + YAMLWarningОпционально перенаправлять предупреждения в логгер (см. Фазу 6)

Вывод: стандартное поведение пакета yaml уже безопаснее того, что изначально требовалось в техническом задании через FAILSAFE_SCHEMA из js-yaml. Вызов блокировки схемы не требуется.

Фаза 5 — Семантика восстановления

CC выбирает мягкий подход “предупредить и отбросить” на каждом уровне:

  1. Парсер YAML выбрасывает ошибку → парсер frontmatter логирует её и возвращает {} (пустые данные)
  2. Поле имеет неверную структуру (например, mcpServers: "this is a string") → safeParse завершается с ошибкой → поле удаляется из итоговой конфигурации
  3. Поле имеет почти неверную структуру (например, отдельный элемент mcpServers является строкой, когда схема ожидает объект) → safeParse для каждого элемента удаляет только этот элемент, сохраняя остальные

qwen-code уже реализует подход “предупредить и отбросить” для каждого поля для permissionMode, maxTurns, color, effort (см. packages/core/src/subagents/agent-frontmatter-schema.ts). Мы расширяем этот же паттерн на mcpServers и hooks.

Что мы НЕ копируем из CC:

  • 2-проходное восстановление YAML с автоматическим экранированием. Это мертвый груз для qwen-code — мы новый проект, у нас нет устаревших файлов frontmatter, отредактированных вручную, которые нужно прощать. Чистая ошибка полезнее, чем угаданная переинтерпретация.
  • События телеметрии tengu_*. Заменены собственным логгером qwen-code / любым слоем телеметрии, который использует остальная часть загрузчика.

Фаза 6 — Рекомендации для qwen-code

Выбор библиотеки

  • Использовать yaml@^2.8.1 (уже является транзитивной зависимостью — повысить до прямой зависимости в packages/core/package.json, чтобы не сломаться в более строгих режимах резолвинга; также это позволит зафиксировать мажорную версию).
  • Использовать схему по умолчанию (core), без флага схемы.
  • Не передавать { merge: true }. Не включать никакие нестандартные опции.
  • Для детерминированного вывода stringify (снапшоты тестов) передавать { lineWidth: 0, defaultStringType: 'PLAIN' } в yaml.stringify, чтобы библиотека не переносила длинные строки и не переключалась произвольно на блочное экранирование скаляров в зависимости от длины контента.

Сохраняемая поверхность API

Текущий packages/core/src/utils/yaml-parser.ts экспортирует:

export function parse(yamlString: string): Record<string, unknown>; export function stringify( obj: Record<string, unknown>, options?: { lineWidth?: number; minContentWidth?: number }, ): string;

Замена сохраняет обе сигнатуры идентичными, поэтому 5 вызывающих сторон (subagent-manager.ts, claude-converter.ts, rulesDiscovery.ts, skill-manager.ts, skill-load.ts) и реэкспорт в index.ts не потребуют никаких изменений в местах вызова.

Набросок реализации:

import * as yaml from 'yaml'; export function parse(yamlString: string): Record<string, unknown> { const parsed = yaml.parse(yamlString); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed as Record<string, unknown>; } return {}; } export function stringify( obj: Record<string, unknown>, options?: { lineWidth?: number; minContentWidth?: number }, ): string { return yaml.stringify(obj, { lineWidth: options?.lineWidth ?? 0, minContentWidth: options?.minContentWidth ?? 20, }); }

Почему мы приводим не-объектные верхнеуровневые значения к {}: каждый существующий вызывающий код ожидает запись (record). YAML-файл, который парсится в null (пустой файл), ["foo"] (список) или "hello" (голый скаляр), в настоящее время приведет к краху деструктуризации ниже по коду. Возврат {} сохраняет поведение старого самописного парсера на тех же входных данных. Задокументируйте это как преднамеренное защитное ограничение в однострочном комментарии.

Вызывающие стороны, не требующие изменений

ФайлИспользованиеСовместимо?
packages/core/src/index.ts:360реэкспортирует * из yaml-parserда — те же имена
packages/core/src/subagents/subagent-manager.ts:15parse, stringifyда
packages/core/src/extension/claude-converter.ts:26parse, stringifyда — round-trip теперь безопасен для mcpServers + hooks (см. Фазу 3)
packages/core/src/utils/rulesDiscovery.ts:20parse as parseYamlда
packages/core/src/skills/skill-manager.ts:13parse as parseYamlimport * as yaml from 'yaml' отдельно)да — и дублирующийся import * as yaml можно удалить в последующем коммите
packages/core/src/skills/skill-load.ts:11parse as parseYamlда

Необходимые тестовые фикстуры

Три конкретных YAML-фрагмента, на которых ломается текущий самописный парсер и которые должна обрабатывать замена (по одному на каждую вложенную структуру):

# Фикстура 1 — mcpServers (запись записей) mcpServers: filesystem: type: stdio command: node args: - /path/to/server.js env: DEBUG: '1' github: type: http url: https://mcp.example.com/github headers: Authorization: 'Bearer xxx'
# Фикстура 2 — hooks (запись массивов записей, два уровня вложенности под именем события) hooks: PreToolUse: - matcher: 'Read|Write' hooks: - type: command command: echo before timeout: 5000 PostToolUse: - matcher: '*' hooks: - type: command command: echo after
# Фикстура 3 — смешанные мелкие + глубокие, плюс всё, что уже поддерживает PR #4842 name: agent-x description: test permissionMode: acceptEdits maxTurns: 5 color: cyan tools: - Read - Write mcpServers: filesystem: type: stdio command: node hooks: PreToolUse: - matcher: Bash hooks: - type: command command: log

Тесты, которые необходимо изменить

В packages/core/src/utils/yaml-parser.test.ts внизу (строки 200–227) есть 2 “закрепляющих теста” с заголовком known limitations — nested YAML (pin until js-yaml lands). Замена ДОЛЖНА превратить их в позитивные утверждения для парсинга вложенных структур:

it('parses array-of-records', () => { const yaml = 'mcpServers:\n - filesystem:\n type: stdio\n command: node'; expect(parse(yaml)).toEqual({ mcpServers: [{ filesystem: { type: 'stdio', command: 'node' } }], }); }); it('parses record-of-records', () => { const yaml = 'hooks:\n PreToolUse:\n - matcher: Read'; expect(parse(yaml)).toEqual({ hooks: { PreToolUse: [{ matcher: 'Read' }] }, }); });

Эти два утверждения плюс три фикстуры выше являются критерием приемки для Фазы 2 плана реализации. Всё остальное (граничные случаи экранирования, булевы значения в кавычках и без, числовые строки) — это регрессионное покрытие из существующего набора тестов, и оно должно проходить без изменений.

Проверка паритета round-trip

Существующий тест should maintain round-trip integrity for escaped strings (строки 111-129) прогоняет 7 строк через stringify → parse. Стандартный stringify из yaml выдает немного другой результат, чем самописный форматтер (в некоторых случаях более агрессивное экранирование, другие escape-последовательности). Два допустимых исхода:

  1. Адаптировать тестовые фикстуры для проверки поведения под новым парсером — важно свойство round-trip (parse(stringify(x)) === x), а не побайтово идентичный вывод YAML.
  2. Оставить побайтово идентичные утверждения и дать им явно упасть, затем обновить их, чтобы они в точности отражали вывод yaml. Так легче ревьюить дифф.

Рекомендация: вариант 1 — изменить утверждения на основанные на свойствах (expect(parse(stringify(obj))).toEqual(obj)), так как побайтово идентичный вывод YAML не является задокументированным контрактом модуля.

Критические изменения для вызывающих сторон — не ожидаются, но проверьте

  • subagent-manager.ts повторно сериализует распарсенный объект обратно в YAML для пути saveSubagent. С новым парсером mcpServers и hooks будут чисто проходить round-trip. Обновите NESTED_FIELDS_NOT_ROUND_TRIPPABLE в claude-converter.ts (Фаза 3 реализации), чтобы убрать эти два имени поля.
  • skill-manager.ts уже импортирует yaml напрямую (отдельно от самописного парсера). Как только yaml-parser.ts тоже начнет использовать yaml, дублирующийся импорт можно будет удалить в рамках небольшого последующего коммита — это вне скок данной задачи.

Риски миграции

Низкие. Все 5 мест вызова деструктурируют Record<string, unknown> — тип возвращаемого значения тот же. Единственные ожидаемые падения — это 2 намеренных pin-теста “garbles”; они известны, и мы намеренно их инвертируем. Более широкое покрытие регрессиями обеспечивается существующими наборами тестов в packages/core/src/subagents/, packages/core/src/skills/ и packages/core/src/extension/.

Открытые вопросы

#ВопросБлокирующий?Путь решения
Q1Нужен ли yaml.parse явный логгер для перенаправления YAMLWarning (например, Unresolved tag) в логгер qwen-code вместо process.emitWarning?Нет — отложитьЕсли логи в CI станут слишком шумными, пробросить { logLevel: 'silent' } или кастомный колбэк onWarning. Не критично для v1.
Q2Должен ли parse() продолжать возвращать {} для пустой строки / null-документа YAML, или выбрасывать ошибку?Нет — сохранить текущее поведениеТекущая самописная реализация возвращает {}; мы это сохраняем. Добавить регрессионный тест, фиксирующий этот выбор.
Q3Если mcpServers некорректен на верхнем уровне (например, mcpServers: "string"), должен ли весь агент падать при загрузке, или загружаться с отброшенным полем?Да — определяет стратегию warn-and-drop на этапе 3 реализацииРешение: отбросить поле, вывести предупреждение в консоль (паритет с CC DL7 согласно этапу 3 в docs/design/declarative-agents-port.md).
Q4То же, что и Q3, но для hooks: отбросить поле, событие или только отдельный матчер?Да — определяет стратегию warn-and-dropРешение: отбрасывать всё поле hooks при ошибке формы на верхнем уровне. Гранулярность на уровне событий/матчеров откладывается до будущего PR, если реальным пользователям это понадобится.
Q5Применимо ли сокращение Bun.YAML.parse из хелпера CC к qwen-code?Нетqwen-code не нацелен на рантайм Bun. Пропускаем.

Статус: исследование завершено, готово к реализации этапа 2 (замена yaml-parser.ts) и этапа 3 (повторное добавление mcpServers + hooks в SubagentConfig) согласно docs/design/declarative-agents-port.md.

Last updated on