Merge frontmatter/file-io-and-serialize: parseTaskFile, parseTaskDirectory, serializeFrontmatter

This commit is contained in:
2026-04-27 11:53:13 +00:00
6 changed files with 646 additions and 26 deletions

View File

@@ -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<TaskInputType> {
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<TaskInputType[]> {
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;
}

View File

@@ -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 { splitFrontmatter, parseFrontmatter } from './parse.js';
export { serializeFrontmatter } from './serialize.js'; export { serializeFrontmatter } from './serialize.js';
export { parseTaskFile, parseTaskDirectory } from './file-io.js';

View File

@@ -134,12 +134,3 @@ export function parseFrontmatter(markdown: string): TaskInputType {
return cleaned as 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 [];
}

View File

@@ -1,6 +1,36 @@
// TaskInput → markdown with frontmatter // TaskInput → markdown with YAML frontmatter
export function serializeFrontmatter(_input: unknown): string { import { stringify as yamlStringify } from 'yaml';
// Stub — implementation pending import type { TaskInput as TaskInputType } from '../schema/task.js';
return '';
/**
* 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<string, unknown> = {};
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}`;
} }

View File

@@ -1,7 +1,7 @@
--- ---
id: frontmatter/file-io-and-serialize id: frontmatter/file-io-and-serialize
name: Implement parseTaskFile, parseTaskDirectory, and serializeFrontmatter name: Implement parseTaskFile, parseTaskDirectory, and serializeFrontmatter
status: pending status: completed
depends_on: depends_on:
- frontmatter/parsing - frontmatter/parsing
- schema/input-schemas - schema/input-schemas
@@ -22,23 +22,23 @@ Per [frontmatter.md](../../../docs/architecture/frontmatter.md):
## Acceptance Criteria ## Acceptance Criteria
- [ ] `parseTaskFile(filePath: string): Promise<TaskInput>`: - [x] `parseTaskFile(filePath: string): Promise<TaskInput>`:
- Reads file using `node:fs/promises.readFile` - Reads file using `node:fs/promises.readFile`
- Delegates to `parseFrontmatter` for parsing and validation - Delegates to `parseFrontmatter` for parsing and validation
- Throws underlying Node.js error for I/O failures (ENOENT, EACCES, etc.) - Throws underlying Node.js error for I/O failures (ENOENT, EACCES, etc.)
- [ ] `parseTaskDirectory(dirPath: string): Promise<TaskInput[]>`: - [x] `parseTaskDirectory(dirPath: string): Promise<TaskInput[]>`:
- Recursive directory scanning via `node:fs/promises.readdir` with `{ recursive: true }` or manual recursion - Recursive directory scanning via `node:fs/promises.readdir` with `{ withFileTypes: true }` + manual recursion
- Filters for `.md` files only - Filters for `.md` files only
- Silently skips files without valid `---`-delimited frontmatter (no error thrown, just omitted from results) - Silently skips files without valid `---`-delimited frontmatter (no error thrown, just omitted from results)
- Throws underlying Node.js error for I/O failures - Throws underlying Node.js error for I/O failures
- Uses `parseTaskFile` per file - Uses `parseTaskFile` per file
- [ ] `serializeFrontmatter(task: TaskInput, body?: string): string`: - [x] `serializeFrontmatter(task: TaskInput, body?: string): string`:
- Constructs `---`-delimited markdown output - 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 `---` - 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 - 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 - [x] 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] 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 ## References
@@ -47,8 +47,14 @@ Per [frontmatter.md](../../../docs/architecture/frontmatter.md):
## Notes ## 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 ## Summary
> To be filled on completion 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

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