* Validate the YAML frontmatter in a plugin component markdown file. * * The runtime loader (parseFrontmatter) silently drops unparseable YAML to a * debug log and returns an empty object. That's the right resilience choice * for the load path, but authors running `claude plugin validate` want a
( filePath: string, content: string, fileType: 'skill' | 'agent' | 'command', )
| 515 | * would silently swallow. |
| 516 | */ |
| 517 | function validateComponentFile( |
| 518 | filePath: string, |
| 519 | content: string, |
| 520 | fileType: 'skill' | 'agent' | 'command', |
| 521 | ): ValidationResult { |
| 522 | const errors: ValidationError[] = [] |
| 523 | const warnings: ValidationWarning[] = [] |
| 524 | |
| 525 | const match = content.match(FRONTMATTER_REGEX) |
| 526 | if (!match) { |
| 527 | warnings.push({ |
| 528 | path: 'frontmatter', |
| 529 | message: |
| 530 | 'No frontmatter block found. Add YAML frontmatter between --- delimiters ' + |
| 531 | 'at the top of the file to set description and other metadata.', |
| 532 | }) |
| 533 | return { success: true, errors, warnings, filePath, fileType } |
| 534 | } |
| 535 | |
| 536 | const frontmatterText = match[1] || '' |
| 537 | let parsed: unknown |
| 538 | try { |
| 539 | parsed = parseYaml(frontmatterText) |
| 540 | } catch (e) { |
| 541 | errors.push({ |
| 542 | path: 'frontmatter', |
| 543 | message: |
| 544 | `YAML frontmatter failed to parse: ${errorMessage(e)}. ` + |
| 545 | `At runtime this ${fileType} loads with empty metadata (all frontmatter ` + |
| 546 | `fields silently dropped).`, |
| 547 | }) |
| 548 | return { success: false, errors, warnings, filePath, fileType } |
| 549 | } |
| 550 | |
| 551 | if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { |
| 552 | errors.push({ |
| 553 | path: 'frontmatter', |
| 554 | message: |
| 555 | 'Frontmatter must be a YAML mapping (key: value pairs), got ' + |
| 556 | `${Array.isArray(parsed) ? 'an array' : parsed === null ? 'null' : typeof parsed}.`, |
| 557 | }) |
| 558 | return { success: false, errors, warnings, filePath, fileType } |
| 559 | } |
| 560 | |
| 561 | const fm = parsed as Record<string, unknown> |
| 562 | |
| 563 | // description: must be scalar. coerceDescriptionToString logs+drops arrays/objects at runtime. |
| 564 | if (fm.description !== undefined) { |
| 565 | const d = fm.description |
| 566 | if ( |
| 567 | typeof d !== 'string' && |
| 568 | typeof d !== 'number' && |
| 569 | typeof d !== 'boolean' && |
| 570 | d !== null |
| 571 | ) { |
| 572 | errors.push({ |
| 573 | path: 'description', |
| 574 | message: |
no test coverage detected