Merge frontmatter/file-io-and-serialize: parseTaskFile, parseTaskDirectory, serializeFrontmatter
This commit is contained in:
77
src/frontmatter/file-io.ts
Normal file
77
src/frontmatter/file-io.ts
Normal 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;
|
||||
}
|
||||
@@ -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 { parseTaskFile, parseTaskDirectory } from './file-io.js';
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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<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}`;
|
||||
}
|
||||
@@ -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<TaskInput>`:
|
||||
- [x] `parseTaskFile(filePath: string): Promise<TaskInput>`:
|
||||
- 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<TaskInput[]>`:
|
||||
- Recursive directory scanning via `node:fs/promises.readdir` with `{ recursive: true }` or manual recursion
|
||||
- [x] `parseTaskDirectory(dirPath: string): Promise<TaskInput[]>`:
|
||||
- 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
|
||||
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
|
||||
515
test/frontmatter-fileio-serialize.test.ts
Normal file
515
test/frontmatter-fileio-serialize.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user