Files
xboct-flow/src/frontmatter/validateFrontmatter.ts
T

261 lines
6.2 KiB
TypeScript

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<string, unknown>;
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;
}