feat(frontmatter/parsing): implement parseFrontmatter with YAML parsing and TypeBox validation
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
// YAML/frontmatter parsing + typebox validation
|
// YAML/frontmatter parsing + typebox validation
|
||||||
|
|
||||||
|
import { parse as yamlParse } from 'yaml';
|
||||||
|
import { Value } from '@alkdev/typebox/value';
|
||||||
|
import { TaskInput, type TaskInput as TaskInputType } from '../schema/task.js';
|
||||||
|
import { InvalidInputError } from '../error/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split a markdown string with `---`-delimited YAML frontmatter into its
|
* Split a markdown string with `---`-delimited YAML frontmatter into its
|
||||||
* data and content parts.
|
* data and content parts.
|
||||||
@@ -73,9 +78,60 @@ export function splitFrontmatter(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseFrontmatter(_input: string): unknown {
|
/**
|
||||||
// Stub — implementation pending
|
* Parse a markdown string with `---`-delimited YAML frontmatter into a
|
||||||
return {};
|
* validated `TaskInput` object.
|
||||||
|
*
|
||||||
|
* Pipeline:
|
||||||
|
* 1. Call `splitFrontmatter()` to extract the YAML data string
|
||||||
|
* 2. Throw `InvalidInputError` if no valid frontmatter found
|
||||||
|
* 3. Parse YAML string with `yaml.parse()` (YAML 1.2 — no type coercion)
|
||||||
|
* 4. Run `Value.Clean()` to strip unknown properties from untrusted input
|
||||||
|
* 5. Run `Value.Check()` — if fails, collect errors via `Value.Errors()` and
|
||||||
|
* throw `InvalidInputError` with field-level details
|
||||||
|
* 6. Return the validated `TaskInput`
|
||||||
|
*
|
||||||
|
* @throws {InvalidInputError} When frontmatter is missing, YAML is invalid,
|
||||||
|
* or schema validation fails
|
||||||
|
*/
|
||||||
|
export function parseFrontmatter(markdown: string): TaskInputType {
|
||||||
|
// Step 1: Split frontmatter
|
||||||
|
const split = splitFrontmatter(markdown);
|
||||||
|
if (split === null) {
|
||||||
|
throw new InvalidInputError('', 'No valid frontmatter found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Parse YAML (YAML 1.2 by default from the `yaml` package)
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = yamlParse(split.data);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new InvalidInputError('', `YAML parse error: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: parsed YAML must be a plain object (not a string, number, null, etc.)
|
||||||
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||||
|
throw new InvalidInputError('', 'YAML frontmatter must be a mapping (object), not a scalar or array');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Clean — strip unknown properties from untrusted input
|
||||||
|
const cleaned = Value.Clean(TaskInput, parsed);
|
||||||
|
|
||||||
|
// Step 5: Validate — check against schema, collect errors if invalid
|
||||||
|
if (!Value.Check(TaskInput, cleaned)) {
|
||||||
|
// Collect all field-level errors from TypeBox's Value.Errors()
|
||||||
|
const errors = [...Value.Errors(TaskInput, cleaned)];
|
||||||
|
// Use the first error to populate InvalidInputError (provides most actionable detail)
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw InvalidInputError.fromTypeBoxError(errors[0]!);
|
||||||
|
}
|
||||||
|
// Fallback if no errors were reported (shouldn't happen, but defensive)
|
||||||
|
throw new InvalidInputError('', 'Schema validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Return validated TaskInput
|
||||||
|
return cleaned as TaskInputType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseTaskFile(_input: string): unknown {
|
export function parseTaskFile(_input: string): unknown {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: frontmatter/parsing
|
id: frontmatter/parsing
|
||||||
name: Implement parseFrontmatter with YAML parsing and TypeBox validation
|
name: Implement parseFrontmatter with YAML parsing and TypeBox validation
|
||||||
status: pending
|
status: completed
|
||||||
depends_on:
|
depends_on:
|
||||||
- frontmatter/splitter
|
- frontmatter/splitter
|
||||||
- schema/input-schemas
|
- schema/input-schemas
|
||||||
@@ -24,17 +24,17 @@ Per [frontmatter.md](../../../docs/architecture/frontmatter.md), the function us
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] `parseFrontmatter(markdown: string): TaskInput`:
|
- [x] `parseFrontmatter(markdown: string): TaskInput`:
|
||||||
- Calls splitter to extract YAML string
|
- Calls splitter to extract YAML string
|
||||||
- Throws `InvalidInputError` if no valid frontmatter found (not `null` return — the caller expects TaskInput)
|
- Throws `InvalidInputError` if no valid frontmatter found (not `null` return — the caller expects TaskInput)
|
||||||
- Calls `yaml.parse(yamlString)` for YAML 1.2 parsing
|
- Calls `yaml.parse(yamlString)` for YAML 1.2 parsing
|
||||||
- Runs `Value.Clean(TaskInput, parsed)` to strip unknown properties
|
- Runs `Value.Clean(TaskInput, parsed)` to strip unknown properties
|
||||||
- Runs `Value.Check(TaskInput, cleaned)` — if fails, runs `Value.Errors()` and throws `InvalidInputError` with structured field/path/message/value details
|
- Runs `Value.Check(TaskInput, cleaned)` — if fails, runs `Value.Errors()` and throws `InvalidInputError` with structured field/path/message/value details
|
||||||
- Returns validated `TaskInput`
|
- Returns validated `TaskInput`
|
||||||
- [ ] `InvalidInputError` is populated with field-level details from `Value.Errors()` output
|
- [x] `InvalidInputError` is populated with field-level details from `Value.Errors()` output
|
||||||
- [ ] YAML 1.2 used exclusively (the `yaml` package default) — no YAML 1.1 type coercion
|
- [x] YAML 1.2 used exclusively (the `yaml` package default) — no YAML 1.1 type coercion
|
||||||
- [ ] Handles YAML `null` values (e.g., `risk:` with no value) correctly — becomes `null` in the TaskInput (distinction from absent field)
|
- [x] Handles YAML `null` values (e.g., `risk:` with no value) correctly — becomes `null` in the TaskInput (distinction from absent field)
|
||||||
- [ ] Unit tests: valid frontmatter, missing required fields, invalid enum values, unknown fields stripped, null categorical values preserved
|
- [x] Unit tests: valid frontmatter, missing required fields, invalid enum values, unknown fields stripped, null categorical values preserved
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -44,8 +44,11 @@ Per [frontmatter.md](../../../docs/architecture/frontmatter.md), the function us
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
> To be filled by implementation agent
|
All acceptance criteria verified by unit tests and type-checker.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> To be filled on completion
|
Implemented `parseFrontmatter(markdown: string): TaskInput` with full YAML 1.2 parsing and TypeBox validation pipeline.
|
||||||
|
- Modified: `src/frontmatter/parse.ts` — replaced stub with full implementation (splitter → yaml.parse → Value.Clean → Value.Check → Value.Errors → InvalidInputError)
|
||||||
|
- Modified: `test/frontmatter.test.ts` — added 23 new tests for parseFrontmatter (total 41 tests in file)
|
||||||
|
- Tests: 41 frontmatter tests, all passing; 211 total across all test files, all passing
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
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', () => {
|
describe('splitFrontmatter', () => {
|
||||||
// ─── Standard frontmatter ────────────────────────────────────────────
|
// ─── Standard frontmatter ────────────────────────────────────────────
|
||||||
@@ -177,4 +178,310 @@ Body starts here`;
|
|||||||
const result = splitFrontmatter('---');
|
const result = splitFrontmatter('---');
|
||||||
expect(result).toBeNull();
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user