import * as yaml from "js-yaml"; import { MAX_TASK_TITLE_LENGTH } from "@/model/taskFile"; import { isTaskPriority, TASK_PRIORITY_ORDER } from "@/model/taskPriority"; import { isTaskStatus, TASK_STATUS_ORDER } from "@/model/taskStatus"; import { extractFrontmatterBlock, findFieldRange, type TextRange, } from "./frontmatterBlock"; export type FrontmatterIssueSeverity = "error" | "warning"; export type FrontmatterIssue = { field?: string; message: string; severity: FrontmatterIssueSeverity; range: TextRange; }; const KNOWN_KEYS = new Set([ "id", "title", "status", "priority", "tags", "assignee", "created", "updated", ]); function isParseableDate(value: unknown): boolean { if (value instanceof Date) { return !Number.isNaN(value.getTime()); } if (typeof value !== "string" || value.trim() === "") { return false; } const ms = Date.parse(value); return !Number.isNaN(ms); } function fieldRangeOrBlock( content: string, key: string, blockRange: TextRange, ): TextRange { return findFieldRange(content, key) ?? blockRange; } /** * Validate task file frontmatter. Pure — no vscode. * Ranges are 0-based line/column. */ export function validateFrontmatter(content: string): FrontmatterIssue[] { const issues: FrontmatterIssue[] = []; const block = extractFrontmatterBlock(content); if (!block) { issues.push({ message: "Task file must start with YAML frontmatter (---)", severity: "error", range: { startLine: 0, startCol: 0, endLine: 0, endCol: 0 }, }); return issues; } if (block.closeLine < 0) { issues.push({ message: "Frontmatter is not closed (missing closing ---)", severity: "error", range: block.range, }); return issues; } let data: unknown; try { data = yaml.load(block.yaml); } catch (error) { const detail = error instanceof Error ? error.message : "invalid YAML"; issues.push({ message: `Invalid YAML: ${detail}`, severity: "error", range: block.range, }); return issues; } if (data === null || data === undefined) { issues.push({ message: "Frontmatter is empty", severity: "error", range: block.range, }); return issues; } if (typeof data !== "object" || Array.isArray(data)) { issues.push({ message: "Frontmatter must be a YAML mapping", severity: "error", range: block.range, }); return issues; } const record = data as Record; const blockRange = block.range; for (const key of Object.keys(record)) { if (!KNOWN_KEYS.has(key)) { issues.push({ field: key, message: `Unknown field "${key}"`, severity: "warning", range: fieldRangeOrBlock(content, key, blockRange), }); } } // id if (record.id === undefined) { issues.push({ field: "id", message: 'Missing required field "id"', severity: "error", range: blockRange, }); } else if (typeof record.id !== "string" || record.id.trim() === "") { issues.push({ field: "id", message: '"id" must be a non-empty string', severity: "error", range: fieldRangeOrBlock(content, "id", blockRange), }); } // title if (record.title === undefined) { issues.push({ field: "title", message: 'Missing required field "title"', severity: "error", range: blockRange, }); } else if (typeof record.title !== "string" || record.title.trim() === "") { issues.push({ field: "title", message: '"title" must be a non-empty string', severity: "error", range: fieldRangeOrBlock(content, "title", blockRange), }); } else if (record.title.length > MAX_TASK_TITLE_LENGTH) { issues.push({ field: "title", message: `"title" must be at most ${MAX_TASK_TITLE_LENGTH} characters (now ${record.title.length})`, severity: "error", range: fieldRangeOrBlock(content, "title", blockRange), }); } // status if (record.status === undefined) { issues.push({ field: "status", message: 'Missing required field "status"', severity: "error", range: blockRange, }); } else if (!isTaskStatus(record.status)) { issues.push({ field: "status", message: `"status" must be one of: ${TASK_STATUS_ORDER.join(", ")}`, severity: "error", range: fieldRangeOrBlock(content, "status", blockRange), }); } // priority if (record.priority === undefined) { issues.push({ field: "priority", message: 'Missing required field "priority"', severity: "error", range: blockRange, }); } else if (!isTaskPriority(record.priority)) { issues.push({ field: "priority", message: `"priority" must be one of: ${TASK_PRIORITY_ORDER.join(", ")}`, severity: "error", range: fieldRangeOrBlock(content, "priority", blockRange), }); } // tags (optional) if (record.tags !== undefined) { if (!Array.isArray(record.tags)) { issues.push({ field: "tags", message: '"tags" must be an array of strings', severity: "error", range: fieldRangeOrBlock(content, "tags", blockRange), }); } else if (record.tags.some((t) => typeof t !== "string")) { issues.push({ field: "tags", message: '"tags" must be an array of strings', severity: "error", range: fieldRangeOrBlock(content, "tags", blockRange), }); } } // assignee (optional) if (record.assignee !== undefined && typeof record.assignee !== "string") { issues.push({ field: "assignee", message: '"assignee" must be a string', severity: "error", range: fieldRangeOrBlock(content, "assignee", blockRange), }); } // created if (record.created === undefined) { issues.push({ field: "created", message: 'Missing required field "created"', severity: "error", range: blockRange, }); } else if (!isParseableDate(record.created)) { issues.push({ field: "created", message: '"created" must be a parseable date (ISO 8601 recommended)', severity: "error", range: fieldRangeOrBlock(content, "created", blockRange), }); } // updated if (record.updated === undefined) { issues.push({ field: "updated", message: 'Missing required field "updated"', severity: "error", range: blockRange, }); } else if (!isParseableDate(record.updated)) { issues.push({ field: "updated", message: '"updated" must be a parseable date (ISO 8601 recommended)', severity: "error", range: fieldRangeOrBlock(content, "updated", blockRange), }); } return issues; }