diff --git a/src/frontmatter/file-io.ts b/src/frontmatter/file-io.ts new file mode 100644 index 0000000..483c632 --- /dev/null +++ b/src/frontmatter/file-io.ts @@ -0,0 +1,77 @@ +// File I/O functions for frontmatter parsing. +// +// ⚠️ These functions depend on `node:fs/promises` and are only available in +// Node.js-compatible runtimes. Consumers targeting Deno, Bun, or browsers +// should use `parseFrontmatter` directly with their own file-reading mechanism. + +import { readFile, readdir } from 'node:fs/promises'; +import { join, extname } from 'node:path'; +import { parseFrontmatter } from './parse.js'; +import type { TaskInput as TaskInputType } from '../schema/task.js'; + +/** + * Read a markdown file and parse its YAML frontmatter into a validated + * `TaskInput` object. + * + * Delegates to `parseFrontmatter` for parsing and validation. + * Throws the underlying Node.js error for I/O failures (ENOENT, EACCES, etc.). + * + * @param filePath — Absolute or relative path to a `.md` file + * @returns Validated `TaskInput` object + * @throws Node.js system errors for I/O failures + * @throws {InvalidInputError} When frontmatter is missing or invalid + * + * @nodeOnly Uses `node:fs/promises.readFile` + */ +export async function parseTaskFile(filePath: string): Promise { + const content = await readFile(filePath, 'utf-8'); + return parseFrontmatter(content); +} + +/** + * Recursively scan a directory for `.md` files and parse each one that + * contains valid `---`-delimited YAML frontmatter. + * + * - Files without valid frontmatter are silently skipped (no error thrown, + * just omitted from the results). + * - Non-`.md` files are ignored. + * - Throws the underlying Node.js error for I/O failures (ENOENT, EACCES). + * - Uses `parseTaskFile` per file. + * + * @param dirPath — Absolute or relative path to a directory + * @returns Array of validated `TaskInput` objects (one per valid file) + * @throws Node.js system errors for I/O failures + * + * @nodeOnly Uses `node:fs/promises.readdir` and `node:fs/promises.stat` + */ +export async function parseTaskDirectory(dirPath: string): Promise { + const results: TaskInputType[] = []; + const entries = await readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dirPath, entry.name); + + if (entry.isDirectory()) { + // Recurse into subdirectories + const subResults = await parseTaskDirectory(fullPath); + results.push(...subResults); + } else if (entry.isFile() && extname(entry.name).toLowerCase() === '.md') { + // Only process .md files; skip others silently + try { + const task = await parseTaskFile(fullPath); + results.push(task); + } catch (err) { + // Silently skip files without valid frontmatter (InvalidInputError + // from parseFrontmatter means no valid --- delimiters found, or + // the YAML/schema is invalid). Rethrow I/O errors. + if (err instanceof Error && err.name === 'InvalidInputError') { + continue; + } + throw err; + } + } + // Non-.md files and other entry types are silently skipped + } + + return results; +} \ No newline at end of file diff --git a/src/frontmatter/index.ts b/src/frontmatter/index.ts index a893198..9bd11df 100644 --- a/src/frontmatter/index.ts +++ b/src/frontmatter/index.ts @@ -1,4 +1,5 @@ -// Frontmatter submodule — parse and serialize +// Frontmatter submodule — parse, serialize, and file I/O -export { splitFrontmatter, parseFrontmatter, parseTaskFile, parseTaskDirectory } from './parse.js'; -export { serializeFrontmatter } from './serialize.js'; \ No newline at end of file +export { splitFrontmatter, parseFrontmatter } from './parse.js'; +export { serializeFrontmatter } from './serialize.js'; +export { parseTaskFile, parseTaskDirectory } from './file-io.js'; \ No newline at end of file diff --git a/src/frontmatter/parse.ts b/src/frontmatter/parse.ts index d3e4b64..b3433e5 100644 --- a/src/frontmatter/parse.ts +++ b/src/frontmatter/parse.ts @@ -134,12 +134,3 @@ export function parseFrontmatter(markdown: string): TaskInputType { return cleaned as TaskInputType; } -export function parseTaskFile(_input: string): unknown { - // Stub — implementation pending - return {}; -} - -export function parseTaskDirectory(_dir: string): unknown[] { - // Stub — implementation pending - return []; -} \ No newline at end of file diff --git a/src/frontmatter/serialize.ts b/src/frontmatter/serialize.ts index 1e96a63..535b883 100644 --- a/src/frontmatter/serialize.ts +++ b/src/frontmatter/serialize.ts @@ -1,6 +1,36 @@ -// TaskInput → markdown with frontmatter +// TaskInput → markdown with YAML frontmatter -export function serializeFrontmatter(_input: unknown): string { - // Stub — implementation pending - return ''; +import { stringify as yamlStringify } from 'yaml'; +import type { TaskInput as TaskInputType } from '../schema/task.js'; + +/** + * Serialize a `TaskInput` object into a `---`-delimited markdown string. + * + * - Uses `yaml.stringify()` for the data portion (YAML 1.2 output). + * - Includes all `TaskInput` fields in the YAML (per schema convention). + * - Nullable fields set to `null` produce explicit `null` in YAML. + * - Absent (undefined) fields are omitted from the YAML output. + * - Appends optional body content (default: empty string) after the + * closing `---`. + * + * @param task — The `TaskInput` object to serialize + * @param body — Optional markdown body content after the frontmatter + * @returns A complete markdown string with frontmatter and optional body + */ +export function serializeFrontmatter(task: TaskInputType, body?: string): string { + // Build a clean object: include all defined fields, omit undefined ones. + // This ensures `yaml.stringify()` only writes keys that are present, + // keeping the output compact and matching what `parseFrontmatter` expects. + const data: Record = {}; + + for (const [key, value] of Object.entries(task)) { + if (value !== undefined) { + data[key] = value; + } + } + + const yaml = yamlStringify(data, { lineWidth: 0 }); + const content = body ?? ''; + + return `---\n${yaml}---\n${content}`; } \ No newline at end of file diff --git a/tasks/implementation/frontmatter/file-io-and-serialize.md b/tasks/implementation/frontmatter/file-io-and-serialize.md index 4a94370..418573d 100644 --- a/tasks/implementation/frontmatter/file-io-and-serialize.md +++ b/tasks/implementation/frontmatter/file-io-and-serialize.md @@ -1,7 +1,7 @@ --- id: frontmatter/file-io-and-serialize name: Implement parseTaskFile, parseTaskDirectory, and serializeFrontmatter -status: pending +status: completed depends_on: - frontmatter/parsing - schema/input-schemas @@ -22,23 +22,23 @@ Per [frontmatter.md](../../../docs/architecture/frontmatter.md): ## Acceptance Criteria -- [ ] `parseTaskFile(filePath: string): Promise`: +- [x] `parseTaskFile(filePath: string): Promise`: - Reads file using `node:fs/promises.readFile` - Delegates to `parseFrontmatter` for parsing and validation - Throws underlying Node.js error for I/O failures (ENOENT, EACCES, etc.) -- [ ] `parseTaskDirectory(dirPath: string): Promise`: - - Recursive directory scanning via `node:fs/promises.readdir` with `{ recursive: true }` or manual recursion +- [x] `parseTaskDirectory(dirPath: string): Promise`: + - Recursive directory scanning via `node:fs/promises.readdir` with `{ withFileTypes: true }` + manual recursion - Filters for `.md` files only - Silently skips files without valid `---`-delimited frontmatter (no error thrown, just omitted from results) - Throws underlying Node.js error for I/O failures - Uses `parseTaskFile` per file -- [ ] `serializeFrontmatter(task: TaskInput, body?: string): string`: +- [x] `serializeFrontmatter(task: TaskInput, body?: string): string`: - Constructs `---`-delimited markdown output - - Uses `yaml.stringify()` for the `TaskInput` data (excludes `id` from frontmatter? No — per Rust CLI convention, `id` comes from the filename, but in the schema it's part of `TaskInput`. Follow schema: include all `TaskInput` fields in the YAML.) + - Uses `yaml.stringify()` for the `TaskInput` data (includes all `TaskInput` fields per schema) - Appends body content (default: empty string) after closing `---` - Handles nullable fields correctly: `risk: null` → `risk: null` in YAML (explicit null), absent fields → omitted from YAML -- [ ] File I/O functions documented as Node.js-only in JSDoc comments -- [ ] Unit tests: parseTaskFile with temp file, parseTaskDirectory with temp dir (including non-.md files, missing frontmatter files), serializeFrontmatter round-trip parseFrontmatter(serializeFrontmatter(task)) ≈ task +- [x] File I/O functions documented as Node.js-only in JSDoc comments +- [x] Unit tests: parseTaskFile with temp file, parseTaskDirectory with temp dir (including non-.md files, missing frontmatter files), serializeFrontmatter round-trip parseFrontmatter(serializeFrontmatter(task)) ≈ task ## References @@ -47,8 +47,14 @@ Per [frontmatter.md](../../../docs/architecture/frontmatter.md): ## Notes -> To be filled by implementation agent +All acceptance criteria verified by unit tests and TypeScript type-checker. File I/O functions moved to separate `file-io.ts` module to keep `parse.ts` runtime-agnostic. Stubs removed from `parse.ts`. ## Summary -> To be filled on completion \ No newline at end of file +Implemented `parseTaskFile`, `parseTaskDirectory`, and `serializeFrontmatter`. +- Created: `src/frontmatter/file-io.ts` (parseTaskFile, parseTaskDirectory — Node.js-only file I/O) +- Modified: `src/frontmatter/serialize.ts` (replaced stub with full serializeFrontmatter implementation) +- Modified: `src/frontmatter/parse.ts` (removed parseTaskFile/parseTaskDirectory stubs) +- Modified: `src/frontmatter/index.ts` (updated exports — file-io functions from file-io.js) +- Created: `test/frontmatter-fileio-serialize.test.ts` (29 tests) +- Tests: 29 new + 257 existing = 286 total, all passing; tsc --noEmit clean \ No newline at end of file diff --git a/test/frontmatter-fileio-serialize.test.ts b/test/frontmatter-fileio-serialize.test.ts new file mode 100644 index 0000000..1a4428d --- /dev/null +++ b/test/frontmatter-fileio-serialize.test.ts @@ -0,0 +1,515 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { parseTaskFile, parseTaskDirectory } from '../src/frontmatter/file-io.js'; +import { serializeFrontmatter } from '../src/frontmatter/serialize.js'; +import { parseFrontmatter } from '../src/frontmatter/parse.js'; +import type { TaskInput } from '../src/schema/task.js'; +import { InvalidInputError } from '../src/error/index.js'; + +describe('parseTaskFile', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'taskgraph-test-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('reads and parses a valid markdown file with frontmatter', async () => { + const filePath = join(tempDir, 'task.md'); + const content = `--- +id: file-task +name: File Task +dependsOn: [] +--- +Some body content`; + await writeFile(filePath, content, 'utf-8'); + + const result = await parseTaskFile(filePath); + expect(result.id).toBe('file-task'); + expect(result.name).toBe('File Task'); + expect(result.dependsOn).toEqual([]); + }); + + it('reads and parses a file with all optional fields', async () => { + const filePath = join(tempDir, 'full-task.md'); + const content = `--- +id: full-file-task +name: Full File Task +dependsOn: + - task-a +status: in-progress +scope: narrow +risk: high +impact: component +level: implementation +priority: critical +tags: + - urgent +assignee: alice +due: "2026-06-01" +--- +Body here`; + await writeFile(filePath, content, 'utf-8'); + + const result = await parseTaskFile(filePath); + expect(result.id).toBe('full-file-task'); + expect(result.status).toBe('in-progress'); + expect(result.risk).toBe('high'); + expect(result.tags).toEqual(['urgent']); + }); + + it('throws ENOENT error for non-existent file', async () => { + const filePath = join(tempDir, 'does-not-exist.md'); + await expect(parseTaskFile(filePath)).rejects.toThrow(); + try { + await parseTaskFile(filePath); + expect.unreachable('Should have thrown'); + } catch (err: any) { + expect(err.code).toBe('ENOENT'); + } + }); + + it('throws InvalidInputError for file with no valid frontmatter', async () => { + const filePath = join(tempDir, 'no-frontmatter.md'); + await writeFile(filePath, 'Just some markdown content\nNo frontmatter', 'utf-8'); + + await expect(parseTaskFile(filePath)).rejects.toThrow(InvalidInputError); + }); + + it('throws InvalidInputError for file with invalid YAML frontmatter', async () => { + const filePath = join(tempDir, 'invalid-yaml.md'); + await writeFile(filePath, `--- +id: bad yaml: [unclosed +name: Broken +dependsOn: [] +---`, 'utf-8'); + + await expect(parseTaskFile(filePath)).rejects.toThrow(InvalidInputError); + }); + + it('handles UTF-8 BOM in file', async () => { + const filePath = join(tempDir, 'bom-task.md'); + const content = '\uFEFF---\nid: bom-task\nname: BOM Task\ndependsOn: []\n---\nBody'; + await writeFile(filePath, content, 'utf-8'); + + const result = await parseTaskFile(filePath); + expect(result.id).toBe('bom-task'); + }); +}); + +describe('parseTaskDirectory', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'taskgraph-dir-test-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('parses all .md files with valid frontmatter in a directory', async () => { + await writeFile(join(tempDir, 'task-a.md'), `--- +id: task-a +name: Task A +dependsOn: [] +---`); + await writeFile(join(tempDir, 'task-b.md'), `--- +id: task-b +name: Task B +dependsOn: + - task-a +---`); + + const results = await parseTaskDirectory(tempDir); + expect(results).toHaveLength(2); + const ids = results.map(r => r.id).sort(); + expect(ids).toEqual(['task-a', 'task-b']); + }); + + it('silently skips non-.md files', async () => { + await writeFile(join(tempDir, 'task.md'), `--- +id: task-1 +name: Task 1 +dependsOn: [] +---`); + await writeFile(join(tempDir, 'notes.txt'), 'Some notes'); + await writeFile(join(tempDir, 'config.json'), '{"key": "value"}'); + await writeFile(join(tempDir, 'script.js'), 'console.log("hi")'); + + const results = await parseTaskDirectory(tempDir); + expect(results).toHaveLength(1); + expect(results[0]!.id).toBe('task-1'); + }); + + it('silently skips .md files without valid frontmatter', async () => { + await writeFile(join(tempDir, 'valid.md'), `--- +id: valid-task +name: Valid +dependsOn: [] +---`); + await writeFile(join(tempDir, 'no-frontmatter.md'), 'Just some text\nNo frontmatter here'); + await writeFile(join(tempDir, 'invalid-schema.md'), `--- +id: broken +name: Missing dependsOn +---`); + + const results = await parseTaskDirectory(tempDir); + expect(results).toHaveLength(1); + expect(results[0]!.id).toBe('valid-task'); + }); + + it('recursively scans subdirectories', async () => { + const subDir = join(tempDir, 'subdir'); + const deepDir = join(subDir, 'deep'); + await mkdir(subDir, { recursive: true }); + await mkdir(deepDir, { recursive: true }); + + await writeFile(join(tempDir, 'root.md'), `--- +id: root-task +name: Root +dependsOn: [] +---`); + await writeFile(join(subDir, 'sub.md'), `--- +id: sub-task +name: Sub +dependsOn: [] +---`); + await writeFile(join(deepDir, 'deep.md'), `--- +id: deep-task +name: Deep +dependsOn: [] +---`); + + const results = await parseTaskDirectory(tempDir); + expect(results).toHaveLength(3); + const ids = results.map(r => r.id).sort(); + expect(ids).toEqual(['deep-task', 'root-task', 'sub-task']); + }); + + it('handles empty directory', async () => { + const emptyDir = join(tempDir, 'empty'); + await mkdir(emptyDir); + + const results = await parseTaskDirectory(emptyDir); + expect(results).toEqual([]); + }); + + it('throws ENOENT for non-existent directory', async () => { + const badPath = join(tempDir, 'no-such-dir'); + await expect(parseTaskDirectory(badPath)).rejects.toThrow(); + try { + await parseTaskDirectory(badPath); + expect.unreachable('Should have thrown'); + } catch (err: any) { + expect(err.code).toBe('ENOENT'); + } + }); + + it('handles mixed valid, invalid, and non-.md files in subdirectories', async () => { + const subDir = join(tempDir, 'mixed'); + await mkdir(subDir); + + await writeFile(join(tempDir, 'root-valid.md'), `--- +id: root +name: Root Task +dependsOn: [] +---`); + await writeFile(join(subDir, 'sub-valid.md'), `--- +id: sub +name: Sub Task +dependsOn: [] +---`); + await writeFile(join(subDir, 'no-fm.md'), 'No frontmatter'); + await writeFile(join(subDir, 'data.csv'), 'a,b,c'); + + const results = await parseTaskDirectory(tempDir); + expect(results).toHaveLength(2); + const ids = results.map(r => r.id).sort(); + expect(ids).toEqual(['root', 'sub']); + }); + + it('skips invalid YAML files (not valid frontmatter) silently', async () => { + await writeFile(join(tempDir, 'valid.md'), `--- +id: good +name: Good +dependsOn: [] +---`); + await writeFile(join(tempDir, 'bad-risk.md'), `--- +id: bad-risk +name: Bad Risk +dependsOn: [] +risk: extreme-invalid-value +---`); + + const results = await parseTaskDirectory(tempDir); + expect(results).toHaveLength(1); + expect(results[0]!.id).toBe('good'); + }); +}); + +describe('serializeFrontmatter', () => { + it('serializes a minimal TaskInput with required fields only', () => { + const task: TaskInput = { + id: 'minimal', + name: 'Minimal Task', + dependsOn: [], + }; + + const result = serializeFrontmatter(task); + expect(result).toContain('---'); + expect(result).toContain('id: minimal'); + expect(result).toContain('name: Minimal Task'); + }); + + it('serializes a TaskInput with all fields populated', () => { + const task: TaskInput = { + id: 'full', + name: 'Full Task', + dependsOn: ['task-a', 'task-b'], + status: 'in-progress', + scope: 'narrow', + risk: 'high', + impact: 'component', + level: 'implementation', + priority: 'critical', + tags: ['urgent', 'backend'], + assignee: 'alice', + due: '2026-06-01', + created: '2026-04-01', + modified: '2026-04-20', + }; + + const result = serializeFrontmatter(task); + expect(result).toContain('id: full'); + expect(result).toContain('status: in-progress'); + expect(result).toContain('risk: high'); + expect(result).toContain('assignee: alice'); + }); + + it('omits undefined fields from YAML output', () => { + const task: TaskInput = { + id: 'partial', + name: 'Partial Task', + dependsOn: [], + // status, scope, risk, etc. are undefined + }; + + const result = serializeFrontmatter(task); + // The YAML should NOT contain keys for undefined fields + expect(result).not.toContain('status:'); + expect(result).not.toContain('risk:'); + expect(result).not.toContain('scope:'); + expect(result).not.toContain('impact:'); + expect(result).not.toContain('tags:'); + }); + + it('serializes explicit null values as null in YAML', () => { + const task: TaskInput = { + id: 'null-task', + name: 'Null Task', + dependsOn: [], + risk: null, + scope: null, + status: null, + }; + + const result = serializeFrontmatter(task); + // YAML null representation (empty value or explicit null) + expect(result).toContain('risk:'); + expect(result).toContain('scope:'); + expect(result).toContain('status:'); + }); + + it('starts with opening --- delimiter', () => { + const task: TaskInput = { + id: 'test', + name: 'Test', + dependsOn: [], + }; + + const result = serializeFrontmatter(task); + expect(result.startsWith('---\n')).toBe(true); + }); + + it('includes closing --- delimiter before body', () => { + const task: TaskInput = { + id: 'test', + name: 'Test', + dependsOn: [], + }; + + const result = serializeFrontmatter(task); + // The closing --- must appear after the YAML content + const lines = result.split('\n'); + // Find the second --- (closing delimiter) + const delimiterCount = lines.filter(l => l === '---').length; + expect(delimiterCount).toBeGreaterThanOrEqual(2); + }); + + it('appends body content after closing delimiter', () => { + const task: TaskInput = { + id: 'body-test', + name: 'Body Test', + dependsOn: [], + }; + + const result = serializeFrontmatter(task, '# My Heading\n\nSome content.'); + expect(result).toContain('# My Heading'); + expect(result).toContain('Some content.'); + }); + + it('default body is empty string', () => { + const task: TaskInput = { + id: 'no-body', + name: 'No Body', + dependsOn: [], + }; + + const result = serializeFrontmatter(task); + // After the closing --- there should be just a trailing newline + const closingIndex = result.lastIndexOf('---'); + const afterClosing = result.slice(closingIndex + 3).trim(); + expect(afterClosing).toBe(''); + }); + + // ─── Round-trip tests: parseFrontmatter(serializeFrontmatter(task)) ≈ task ── + + describe('round-trip: parseFrontmatter(serializeFrontmatter(task))', () => { + it('round-trips a minimal TaskInput', () => { + const original: TaskInput = { + id: 'rt-minimal', + name: 'Round Trip Minimal', + dependsOn: [], + }; + + const serialized = serializeFrontmatter(original); + const parsed = parseFrontmatter(serialized); + + expect(parsed.id).toBe(original.id); + expect(parsed.name).toBe(original.name); + expect(parsed.dependsOn).toEqual(original.dependsOn); + }); + + it('round-trips a TaskInput with all fields', () => { + const original: TaskInput = { + id: 'rt-full', + name: 'Round Trip Full', + dependsOn: ['dep-a', 'dep-b'], + status: 'in-progress', + scope: 'narrow', + risk: 'high', + impact: 'component', + level: 'implementation', + priority: 'critical', + tags: ['urgent'], + assignee: 'bob', + due: '2026-06-01', + created: '2026-04-01', + modified: '2026-04-20', + }; + + const serialized = serializeFrontmatter(original); + const parsed = parseFrontmatter(serialized); + + expect(parsed.id).toBe(original.id); + expect(parsed.name).toBe(original.name); + expect(parsed.dependsOn).toEqual(original.dependsOn); + expect(parsed.status).toBe(original.status); + expect(parsed.scope).toBe(original.scope); + expect(parsed.risk).toBe(original.risk); + expect(parsed.impact).toBe(original.impact); + expect(parsed.level).toBe(original.level); + expect(parsed.priority).toBe(original.priority); + expect(parsed.tags).toEqual(original.tags); + expect(parsed.assignee).toBe(original.assignee); + expect(parsed.due).toBe(original.due); + expect(parsed.created).toBe(original.created); + expect(parsed.modified).toBe(original.modified); + }); + + it('round-trips nullable fields set to null', () => { + const original: TaskInput = { + id: 'rt-null', + name: 'Round Trip Null', + dependsOn: [], + risk: null, + status: null, + assignee: null, + }; + + const serialized = serializeFrontmatter(original); + const parsed = parseFrontmatter(serialized); + + expect(parsed.id).toBe(original.id); + expect(parsed.risk).toBeNull(); + expect(parsed.status).toBeNull(); + expect(parsed.assignee).toBeNull(); + // Fields that were undefined in original should be undefined after round-trip + expect(parsed.scope).toBeUndefined(); + expect(parsed.impact).toBeUndefined(); + }); + + it('round-trips with body content preserved separately', () => { + const task: TaskInput = { + id: 'rt-body', + name: 'Round Trip Body', + dependsOn: [], + }; + const body = '# Implementation Notes\n\nSome details here.'; + + const serialized = serializeFrontmatter(task, body); + // The serialized string should contain the body + expect(serialized).toContain('# Implementation Notes'); + + // Parse only the frontmatter portion (parseFrontmatter ignores body) + const parsed = parseFrontmatter(serialized); + expect(parsed.id).toBe('rt-body'); + }); + + it('round-trips with empty dependsOn array', () => { + const original: TaskInput = { + id: 'rt-empty-deps', + name: 'Empty Deps', + dependsOn: [], + }; + + const serialized = serializeFrontmatter(original); + const parsed = parseFrontmatter(serialized); + + expect(parsed.dependsOn).toEqual([]); + }); + + it('round-trips with populated dependsOn array', () => { + const original: TaskInput = { + id: 'rt-deps', + name: 'With Deps', + dependsOn: ['alpha', 'beta', 'gamma'], + }; + + const serialized = serializeFrontmatter(original); + const parsed = parseFrontmatter(serialized); + + expect(parsed.dependsOn).toEqual(['alpha', 'beta', 'gamma']); + }); + + it('round-trips with tags array', () => { + const original: TaskInput = { + id: 'rt-tags', + name: 'With Tags', + dependsOn: [], + tags: ['urgent', 'backend', 'security'], + }; + + const serialized = serializeFrontmatter(original); + const parsed = parseFrontmatter(serialized); + + expect(parsed.tags).toEqual(['urgent', 'backend', 'security']); + }); + }); +}); \ No newline at end of file