261 lines
6.2 KiB
TypeScript
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;
|
|
}
|