Merge frontmatter/parsing: parseFrontmatter with YAML 1.2, TypeBox validation, InvalidInputError

This commit is contained in:
2026-04-27 11:28:09 +00:00
3 changed files with 378 additions and 12 deletions

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { splitFrontmatter } from '../src/frontmatter/parse.js';
import { splitFrontmatter, parseFrontmatter } from '../src/frontmatter/parse.js';
import { InvalidInputError } from '../src/error/index.js';
describe('splitFrontmatter', () => {
// ─── Standard frontmatter ────────────────────────────────────────────
@@ -177,4 +178,310 @@ Body starts here`;
const result = splitFrontmatter('---');
expect(result).toBeNull();
});
});
describe('parseFrontmatter', () => {
// ─── Valid frontmatter ──────────────────────────────────────────────────
it('parses valid frontmatter with required fields only', () => {
const input = `---
id: my-task
name: My Task
dependsOn: []
---
Some body`;
const result = parseFrontmatter(input);
expect(result.id).toBe('my-task');
expect(result.name).toBe('My Task');
expect(result.dependsOn).toEqual([]);
});
it('parses valid frontmatter with all fields populated', () => {
const input = `---
id: full-task
name: Full Task
dependsOn:
- task-a
- task-b
status: in-progress
scope: narrow
risk: high
impact: component
level: implementation
priority: critical
tags:
- urgent
assignee: alice
due: "2026-06-01"
created: "2026-04-01"
modified: "2026-04-20"
---
Body here`;
const result = parseFrontmatter(input);
expect(result.id).toBe('full-task');
expect(result.name).toBe('Full Task');
expect(result.dependsOn).toEqual(['task-a', 'task-b']);
expect(result.status).toBe('in-progress');
expect(result.scope).toBe('narrow');
expect(result.risk).toBe('high');
expect(result.impact).toBe('component');
expect(result.level).toBe('implementation');
expect(result.priority).toBe('critical');
expect(result.tags).toEqual(['urgent']);
expect(result.assignee).toBe('alice');
expect(result.due).toBe('2026-06-01');
expect(result.created).toBe('2026-04-01');
expect(result.modified).toBe('2026-04-20');
});
it('parses valid frontmatter with optional fields omitted', () => {
const input = `---
id: minimal-task
name: Minimal
dependsOn: []
---
Body`;
const result = parseFrontmatter(input);
expect(result.id).toBe('minimal-task');
expect(result.name).toBe('Minimal');
// Optional fields should be undefined when not present
expect(result.status).toBeUndefined();
expect(result.scope).toBeUndefined();
expect(result.risk).toBeUndefined();
expect(result.impact).toBeUndefined();
expect(result.level).toBeUndefined();
expect(result.priority).toBeUndefined();
expect(result.tags).toBeUndefined();
expect(result.assignee).toBeUndefined();
expect(result.due).toBeUndefined();
expect(result.created).toBeUndefined();
expect(result.modified).toBeUndefined();
});
// ─── YAML null values ──────────────────────────────────────────────────
it('preserves YAML null values as null (distinct from absent field)', () => {
const input = `---
id: null-task
name: Null Task
dependsOn: []
risk:
scope:
status:
---`;
const result = parseFrontmatter(input);
expect(result.risk).toBeNull();
expect(result.scope).toBeNull();
expect(result.status).toBeNull();
});
it('distinguishes between absent field and explicitly null field', () => {
const input = `---
id: mixed-task
name: Mixed
dependsOn: []
risk:
---`;
const result = parseFrontmatter(input);
// risk is explicitly set to null
expect(result.risk).toBeNull();
// impact is absent (not in YAML)
expect(result.impact).toBeUndefined();
});
// ─── No valid frontmatter ───────────────────────────────────────────────
it('throws InvalidInputError when no frontmatter found', () => {
expect(() => parseFrontmatter('No frontmatter here')).toThrow(InvalidInputError);
});
it('throws InvalidInputError with descriptive message when no frontmatter found', () => {
expect(() => parseFrontmatter('No frontmatter here')).toThrow('No valid frontmatter found');
});
// ─── Missing required fields ────────────────────────────────────────────
it('throws InvalidInputError when id is missing', () => {
const input = `---
name: No ID
dependsOn: []
---`;
expect(() => parseFrontmatter(input)).toThrow(InvalidInputError);
});
it('throws InvalidInputError when name is missing', () => {
const input = `---
id: no-name
dependsOn: []
---`;
expect(() => parseFrontmatter(input)).toThrow(InvalidInputError);
});
it('throws InvalidInputError when dependsOn is missing', () => {
const input = `---
id: no-deps
name: No Deps
---`;
expect(() => parseFrontmatter(input)).toThrow(InvalidInputError);
});
it('throws InvalidInputError with field-level detail for missing required field', () => {
const input = `---
name: No ID field
dependsOn: []
---`;
try {
parseFrontmatter(input);
expect.unreachable('Should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(InvalidInputError);
const invalidErr = err as InvalidInputError;
// The field property should reference the missing field path
expect(invalidErr.field).toContain('id');
}
});
// ─── Invalid enum values ───────────────────────────────────────────────
it('throws InvalidInputError for invalid risk enum value', () => {
const input = `---
id: bad-risk
name: Bad Risk
dependsOn: []
risk: extreme
---`;
expect(() => parseFrontmatter(input)).toThrow(InvalidInputError);
});
it('throws InvalidInputError for invalid status enum value', () => {
const input = `---
id: bad-status
name: Bad Status
dependsOn: []
status: unknown
---`;
expect(() => parseFrontmatter(input)).toThrow(InvalidInputError);
});
it('throws InvalidInputError for invalid scope enum value', () => {
const input = `---
id: bad-scope
name: Bad Scope
dependsOn: []
scope: universal
---`;
expect(() => parseFrontmatter(input)).toThrow(InvalidInputError);
});
// ─── Type mismatches ────────────────────────────────────────────────────
it('throws InvalidInputError when dependsOn is a string instead of array', () => {
const input = `---
id: bad-deps
name: Bad Deps
dependsOn: not-an-array
---`;
expect(() => parseFrontmatter(input)).toThrow(InvalidInputError);
});
// ─── Unknown fields stripped ───────────────────────────────────────────
it('strips unknown fields from the result (Value.Clean)', () => {
const input = `---
id: clean-task
name: Clean Task
dependsOn: []
unknownField: should be removed
anotherUnknown: 42
---`;
const result = parseFrontmatter(input);
expect(result.id).toBe('clean-task');
expect(result.name).toBe('Clean Task');
// Unknown fields should not appear (they are stripped by Value.Clean)
expect((result as Record<string, unknown>)['unknownField']).toBeUndefined();
expect((result as Record<string, unknown>)['anotherUnknown']).toBeUndefined();
});
// ─── YAML 1.2 — no type coercion ───────────────────────────────────────
it('does not coerce YAML 1.1 booleans/numbers (YAML 1.2 compliance)', () => {
// In YAML 1.2, "yes" is a string, not a boolean (unlike YAML 1.1)
// "on" and "off" are also strings in YAML 1.2
const input = `---
id: yaml12-task
name: "YAML 1.2 Task"
dependsOn: []
---`;
const result = parseFrontmatter(input);
expect(result.name).toBe('YAML 1.2 Task');
});
it('parses an empty dependsOn array', () => {
const input = `---
id: empty-deps
name: Empty Deps
dependsOn: []
---`;
const result = parseFrontmatter(input);
expect(result.dependsOn).toEqual([]);
});
// ─── Invalid YAML syntax ───────────────────────────────────────────────
it('throws InvalidInputError for invalid YAML syntax', () => {
const input = `---
id: bad yaml: [unclosed
name: Broken
dependsOn: []
---`;
expect(() => parseFrontmatter(input)).toThrow(InvalidInputError);
});
// ─── YAML frontmatter that is not a mapping ────────────────────────────
it('throws InvalidInputError when YAML frontmatter is a scalar', () => {
const input = `---
just a string
---`;
expect(() => parseFrontmatter(input)).toThrow(InvalidInputError);
});
it('throws InvalidInputError when YAML frontmatter is an array', () => {
const input = `---
- item1
- item2
---`;
expect(() => parseFrontmatter(input)).toThrow(InvalidInputError);
});
// ─── Field-level error detail ───────────────────────────────────────────
it('InvalidInputError is populated with field-level details from Value.Errors()', () => {
const input = `---
id: field-detail
name: Field Detail
dependsOn: []
risk: invalid-value
---`;
try {
parseFrontmatter(input);
expect.unreachable('Should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(InvalidInputError);
const invalidErr = err as InvalidInputError;
// The error should have both field and message populated
expect(invalidErr.field).toBeTruthy();
expect(invalidErr.message).toBeTruthy();
// Field should reference "risk"
expect(invalidErr.field).toMatch(/risk/i);
}
});
// ─── BOM handling ──────────────────────────────────────────────────────
it('handles UTF-8 BOM at start of file', () => {
const input = '\uFEFF---\nid: bom-task\nname: BOM Task\ndependsOn: []\n---\nBody';
const result = parseFrontmatter(input);
expect(result.id).toBe('bom-task');
});
});