feat(frontmatter/file-io-and-serialize): implement parseTaskFile, parseTaskDirectory, and serializeFrontmatter

This commit is contained in:
2026-04-27 11:51:34 +00:00
parent 9ad0ec902c
commit 6da0cb12ce
6 changed files with 646 additions and 26 deletions

View File

@@ -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']);
});
});
});