@stcrft/statecraft-core 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.js +326 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -0
- package/src/ast.ts +30 -0
- package/src/index.ts +12 -0
- package/src/parser.ts +207 -0
- package/src/summarize.ts +91 -0
- package/src/validation.ts +132 -0
- package/test/fixtures/board.yaml +10 -0
- package/test/parser.test.ts +250 -0
- package/test/summarize.test.ts +103 -0
- package/test/validation.test.ts +153 -0
- package/tsconfig.json +9 -0
- package/tsup.config.ts +19 -0
- package/vitest.config.ts +20 -0
package/src/parser.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
import type { Board, Column, Task } from "./ast.js";
|
|
5
|
+
|
|
6
|
+
/** Thrown when YAML is invalid or required fields are missing/wrong. */
|
|
7
|
+
export class ParseError extends Error {
|
|
8
|
+
constructor(message: string) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "ParseError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function assertObject(value: unknown, path: string): asserts value is Record<string, unknown> {
|
|
15
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
16
|
+
throw new ParseError(`${path}: expected an object`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assertArray(value: unknown, path: string): asserts value is unknown[] {
|
|
21
|
+
if (!Array.isArray(value)) {
|
|
22
|
+
throw new ParseError(`${path}: expected an array`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function assertString(value: unknown, path: string): asserts value is string {
|
|
27
|
+
if (typeof value !== "string") {
|
|
28
|
+
throw new ParseError(`${path}: expected a string`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse YAML board content into a typed Board AST.
|
|
34
|
+
* @param content - Raw YAML string (board file content).
|
|
35
|
+
* @returns Board AST.
|
|
36
|
+
* @throws ParseError when YAML is invalid or required fields are missing/wrong.
|
|
37
|
+
*/
|
|
38
|
+
export function parseBoardFromString(content: string): Board {
|
|
39
|
+
let raw: unknown;
|
|
40
|
+
try {
|
|
41
|
+
raw = parse(content);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
44
|
+
throw new ParseError(`Invalid YAML: ${message}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
assertObject(raw, "root");
|
|
48
|
+
const root = raw as Record<string, unknown>;
|
|
49
|
+
|
|
50
|
+
// board (required, non-empty string)
|
|
51
|
+
if (!("board" in root)) {
|
|
52
|
+
throw new ParseError("Missing required field: board");
|
|
53
|
+
}
|
|
54
|
+
assertString(root.board, "board");
|
|
55
|
+
if (root.board.trim() === "") {
|
|
56
|
+
throw new ParseError("board must be a non-empty string");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// columns (required, non-empty array)
|
|
60
|
+
if (!("columns" in root)) {
|
|
61
|
+
throw new ParseError("Missing required field: columns");
|
|
62
|
+
}
|
|
63
|
+
assertArray(root.columns, "columns");
|
|
64
|
+
if (root.columns.length === 0) {
|
|
65
|
+
throw new ParseError("columns must be a non-empty array");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const columns: Column[] = root.columns.map((item: unknown, i: number) => {
|
|
69
|
+
const path = `columns[${i}]`;
|
|
70
|
+
if (typeof item === "string") {
|
|
71
|
+
if (item.trim() === "") {
|
|
72
|
+
throw new ParseError(`${path}: column name must be non-empty`);
|
|
73
|
+
}
|
|
74
|
+
return { name: item };
|
|
75
|
+
}
|
|
76
|
+
if (item !== null && typeof item === "object" && !Array.isArray(item)) {
|
|
77
|
+
const obj = item as Record<string, unknown>;
|
|
78
|
+
if (!("name" in obj)) {
|
|
79
|
+
throw new ParseError(`${path}: column object must have "name"`);
|
|
80
|
+
}
|
|
81
|
+
assertString(obj.name, `${path}.name`);
|
|
82
|
+
if ((obj.name as string).trim() === "") {
|
|
83
|
+
throw new ParseError(`${path}.name: must be non-empty`);
|
|
84
|
+
}
|
|
85
|
+
const col: Column = { name: obj.name as string };
|
|
86
|
+
if ("limit" in obj && obj.limit !== undefined) {
|
|
87
|
+
if (typeof obj.limit !== "number" || !Number.isInteger(obj.limit) || obj.limit < 1) {
|
|
88
|
+
throw new ParseError(`${path}.limit: must be a positive integer`);
|
|
89
|
+
}
|
|
90
|
+
col.limit = obj.limit;
|
|
91
|
+
}
|
|
92
|
+
return col;
|
|
93
|
+
}
|
|
94
|
+
throw new ParseError(`${path}: expected a string or object with "name" (and optional "limit")`);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// tasks (required, object; may be empty)
|
|
98
|
+
if (!("tasks" in root)) {
|
|
99
|
+
throw new ParseError("Missing required field: tasks");
|
|
100
|
+
}
|
|
101
|
+
if (root.tasks === null || typeof root.tasks !== "object" || Array.isArray(root.tasks)) {
|
|
102
|
+
throw new ParseError("tasks must be an object (map of task id to task)");
|
|
103
|
+
}
|
|
104
|
+
const tasksRaw = root.tasks as Record<string, unknown>;
|
|
105
|
+
const tasks: Record<string, Task> = {};
|
|
106
|
+
|
|
107
|
+
for (const [id, rawTask] of Object.entries(tasksRaw)) {
|
|
108
|
+
const path = `tasks.${id}`;
|
|
109
|
+
assertObject(rawTask, path);
|
|
110
|
+
const t = rawTask as Record<string, unknown>;
|
|
111
|
+
|
|
112
|
+
if (!("title" in t)) {
|
|
113
|
+
throw new ParseError(`${path}: missing required field "title"`);
|
|
114
|
+
}
|
|
115
|
+
assertString(t.title, `${path}.title`);
|
|
116
|
+
|
|
117
|
+
if (!("status" in t)) {
|
|
118
|
+
throw new ParseError(`${path}: missing required field "status"`);
|
|
119
|
+
}
|
|
120
|
+
assertString(t.status, `${path}.status`);
|
|
121
|
+
|
|
122
|
+
const task: Task = {
|
|
123
|
+
title: t.title as string,
|
|
124
|
+
status: t.status as string,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
if ("description" in t && t.description !== undefined) {
|
|
128
|
+
assertString(t.description, `${path}.description`);
|
|
129
|
+
task.description = t.description as string;
|
|
130
|
+
}
|
|
131
|
+
if ("spec" in t && t.spec !== undefined) {
|
|
132
|
+
assertString(t.spec, `${path}.spec`);
|
|
133
|
+
task.spec = t.spec as string;
|
|
134
|
+
}
|
|
135
|
+
if ("owner" in t && t.owner !== undefined) {
|
|
136
|
+
assertString(t.owner, `${path}.owner`);
|
|
137
|
+
task.owner = t.owner as string;
|
|
138
|
+
}
|
|
139
|
+
if ("priority" in t && t.priority !== undefined) {
|
|
140
|
+
assertString(t.priority, `${path}.priority`);
|
|
141
|
+
task.priority = t.priority as string;
|
|
142
|
+
}
|
|
143
|
+
if ("depends_on" in t && t.depends_on !== undefined) {
|
|
144
|
+
if (typeof t.depends_on === "string") {
|
|
145
|
+
task.depends_on = [t.depends_on];
|
|
146
|
+
} else if (Array.isArray(t.depends_on)) {
|
|
147
|
+
const arr = t.depends_on as unknown[];
|
|
148
|
+
for (let i = 0; i < arr.length; i++) {
|
|
149
|
+
assertString(arr[i], `${path}.depends_on[${i}]`);
|
|
150
|
+
}
|
|
151
|
+
task.depends_on = arr as string[];
|
|
152
|
+
} else {
|
|
153
|
+
throw new ParseError(`${path}.depends_on: expected a string or array of strings`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
tasks[id] = task;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
board: root.board as string,
|
|
162
|
+
columns,
|
|
163
|
+
tasks,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Heuristic: treat as file path if no newline and (ends with .yaml/.yml or contains path sep).
|
|
169
|
+
* Otherwise treat as raw YAML content.
|
|
170
|
+
*/
|
|
171
|
+
function looksLikePath(input: string): boolean {
|
|
172
|
+
if (input.includes("\n")) return false;
|
|
173
|
+
const trimmed = input.trim();
|
|
174
|
+
if (trimmed.length === 0) return false;
|
|
175
|
+
return (
|
|
176
|
+
trimmed.endsWith(".yaml") ||
|
|
177
|
+
trimmed.endsWith(".yml") ||
|
|
178
|
+
trimmed.includes("/") ||
|
|
179
|
+
trimmed.includes(path.sep)
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Parse a board from a file path or raw YAML string.
|
|
185
|
+
* - If `input` looks like a path (no newline, ends with .yaml/.yml or contains path separator),
|
|
186
|
+
* the file is read from disk (relative to cwd) and parsed.
|
|
187
|
+
* - Otherwise `input` is parsed as raw YAML content.
|
|
188
|
+
*
|
|
189
|
+
* @param input - File path (absolute or relative to cwd) or raw YAML string.
|
|
190
|
+
* @returns Board AST.
|
|
191
|
+
* @throws ParseError when YAML is invalid or required fields are missing/wrong.
|
|
192
|
+
* @throws ParseError when input is a path and the file cannot be read (e.g. not found).
|
|
193
|
+
*/
|
|
194
|
+
export function parseBoard(input: string): Board {
|
|
195
|
+
if (looksLikePath(input)) {
|
|
196
|
+
const resolved = path.resolve(input.trim());
|
|
197
|
+
let content: string;
|
|
198
|
+
try {
|
|
199
|
+
content = fs.readFileSync(resolved, "utf-8");
|
|
200
|
+
} catch (err) {
|
|
201
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
202
|
+
throw new ParseError(`Cannot read file "${resolved}": ${message}`);
|
|
203
|
+
}
|
|
204
|
+
return parseBoardFromString(content);
|
|
205
|
+
}
|
|
206
|
+
return parseBoardFromString(input);
|
|
207
|
+
}
|
package/src/summarize.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Board, Column } from "./ast.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns a plain-text summary of the board for terminal output or paste into chat/docs.
|
|
5
|
+
* Format: board name, per-column counts, optional WIP limit lines, task list, optional blocked section.
|
|
6
|
+
* @param board - Parsed board AST.
|
|
7
|
+
* @returns Summary string ending with a single newline.
|
|
8
|
+
*/
|
|
9
|
+
export function summarize(board: Board): string {
|
|
10
|
+
const columnNames = board.columns.map((c: Column) => c.name);
|
|
11
|
+
const lastColumnName = columnNames.length > 0 ? columnNames[columnNames.length - 1] : "";
|
|
12
|
+
|
|
13
|
+
// Per-column task counts
|
|
14
|
+
const countByColumn: Record<string, number> = {};
|
|
15
|
+
for (const name of columnNames) {
|
|
16
|
+
countByColumn[name] = 0;
|
|
17
|
+
}
|
|
18
|
+
for (const task of Object.values(board.tasks)) {
|
|
19
|
+
if (task.status in countByColumn) {
|
|
20
|
+
countByColumn[task.status]++;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const lines: string[] = [];
|
|
25
|
+
|
|
26
|
+
// Board name
|
|
27
|
+
lines.push(`Board: ${board.board}`);
|
|
28
|
+
lines.push("");
|
|
29
|
+
|
|
30
|
+
// Columns: name (count), ...
|
|
31
|
+
const columnPart = columnNames.map((name) => `${name} (${countByColumn[name] ?? 0})`).join(", ");
|
|
32
|
+
lines.push(`Columns: ${columnPart}`);
|
|
33
|
+
|
|
34
|
+
// WIP limit: for each column with limit and count >= limit
|
|
35
|
+
for (const col of board.columns) {
|
|
36
|
+
if (col.limit != null && col.limit >= 1) {
|
|
37
|
+
const count = countByColumn[col.name] ?? 0;
|
|
38
|
+
if (count >= col.limit) {
|
|
39
|
+
lines.push(` ${col.name} at WIP limit (${count}/${col.limit})`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
lines.push("");
|
|
45
|
+
|
|
46
|
+
// Tasks: ordered by column order, then by task id within column
|
|
47
|
+
lines.push("Tasks:");
|
|
48
|
+
const taskIds = Object.keys(board.tasks);
|
|
49
|
+
const statusToIndex: Record<string, number> = {};
|
|
50
|
+
columnNames.forEach((name, i) => {
|
|
51
|
+
statusToIndex[name] = i;
|
|
52
|
+
});
|
|
53
|
+
const sortedIds = taskIds.slice().sort((a, b) => {
|
|
54
|
+
const taskA = board.tasks[a];
|
|
55
|
+
const taskB = board.tasks[b];
|
|
56
|
+
const idxA = statusToIndex[taskA.status] ?? 999;
|
|
57
|
+
const idxB = statusToIndex[taskB.status] ?? 999;
|
|
58
|
+
if (idxA !== idxB) return idxA - idxB;
|
|
59
|
+
return a.localeCompare(b);
|
|
60
|
+
});
|
|
61
|
+
if (sortedIds.length === 0) {
|
|
62
|
+
lines.push(" (none)");
|
|
63
|
+
} else {
|
|
64
|
+
for (const id of sortedIds) {
|
|
65
|
+
const task = board.tasks[id];
|
|
66
|
+
lines.push(` ${id} [${task.status}] ${task.title}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Blocked: task has depends_on and at least one dependency is not in last column
|
|
71
|
+
const blocked: { id: string; deps: string[] }[] = [];
|
|
72
|
+
for (const id of taskIds) {
|
|
73
|
+
const task = board.tasks[id];
|
|
74
|
+
const deps = task.depends_on ?? [];
|
|
75
|
+
const unmet = deps.filter((depId) => {
|
|
76
|
+
const dep = board.tasks[depId];
|
|
77
|
+
return dep && dep.status !== lastColumnName;
|
|
78
|
+
});
|
|
79
|
+
if (unmet.length > 0) {
|
|
80
|
+
blocked.push({ id, deps: unmet });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (blocked.length > 0) {
|
|
84
|
+
lines.push("");
|
|
85
|
+
for (const { id, deps } of blocked) {
|
|
86
|
+
lines.push(`Blocked: ${id} (depends on ${deps.join(", ")})`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return lines.join("\n") + "\n";
|
|
91
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Board, Column, Task } from "./ast.js";
|
|
2
|
+
|
|
3
|
+
/** Canonical column set per spec: exact names and order. */
|
|
4
|
+
export const CANONICAL_COLUMNS = ["Backlog", "Ready", "In Progress", "Done"] as const;
|
|
5
|
+
|
|
6
|
+
/** A single validation error (or warning). */
|
|
7
|
+
export interface ValidationError {
|
|
8
|
+
/** Human-readable message. */
|
|
9
|
+
message: string;
|
|
10
|
+
/** Optional path (e.g. "tasks.AUTH-12.status") for tooling. */
|
|
11
|
+
path?: string;
|
|
12
|
+
/** Optional code (e.g. "STATUS_INVALID") for programmatic handling. */
|
|
13
|
+
code?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Result of validating a board. Does not throw; caller decides what to do with errors. */
|
|
17
|
+
export interface ValidationResult {
|
|
18
|
+
/** True if there are no errors. */
|
|
19
|
+
valid: boolean;
|
|
20
|
+
/** List of validation errors (empty if valid). */
|
|
21
|
+
errors: ValidationError[];
|
|
22
|
+
/** Optional warnings (e.g. spec file missing). Empty if none. */
|
|
23
|
+
warnings?: ValidationError[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Run all validation rules on a parsed board. Does not throw; returns a list of errors.
|
|
28
|
+
* @param board - Parsed board AST.
|
|
29
|
+
* @returns List of validation errors (empty if valid).
|
|
30
|
+
*/
|
|
31
|
+
export function validateBoard(board: Board): ValidationError[] {
|
|
32
|
+
const errors: ValidationError[] = [];
|
|
33
|
+
const columnNames = board.columns.map((c) => c.name);
|
|
34
|
+
|
|
35
|
+
// Canonical columns: must be Backlog, Ready, In Progress, Done in that order
|
|
36
|
+
if (board.columns.length !== CANONICAL_COLUMNS.length) {
|
|
37
|
+
errors.push({
|
|
38
|
+
path: "columns",
|
|
39
|
+
message: `Board must have exactly ${CANONICAL_COLUMNS.length} columns: ${CANONICAL_COLUMNS.join(", ")}. Got ${board.columns.length}.`,
|
|
40
|
+
code: "COLUMNS_NOT_CANONICAL",
|
|
41
|
+
});
|
|
42
|
+
} else {
|
|
43
|
+
for (let i = 0; i < CANONICAL_COLUMNS.length; i++) {
|
|
44
|
+
const expected = CANONICAL_COLUMNS[i];
|
|
45
|
+
const actual = board.columns[i]?.name;
|
|
46
|
+
if (actual !== expected) {
|
|
47
|
+
errors.push({
|
|
48
|
+
path: `columns[${i}]`,
|
|
49
|
+
message: `Column at index ${i} must be "${expected}". Got "${actual}". Canonical order: ${CANONICAL_COLUMNS.join(", ")}.`,
|
|
50
|
+
code: "COLUMNS_NOT_CANONICAL",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Column names unique (redundant if canonical check passed, but keeps other checks consistent)
|
|
57
|
+
const seen = new Set<string>();
|
|
58
|
+
for (let i = 0; i < board.columns.length; i++) {
|
|
59
|
+
const name = board.columns[i].name;
|
|
60
|
+
if (seen.has(name)) {
|
|
61
|
+
errors.push({
|
|
62
|
+
path: `columns[${i}]`,
|
|
63
|
+
message: `Duplicate column name: "${name}"`,
|
|
64
|
+
code: "DUPLICATE_COLUMN",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
seen.add(name);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Task status matches column; depends_on refs exist
|
|
71
|
+
const taskIds = new Set(Object.keys(board.tasks));
|
|
72
|
+
for (const [id, task] of Object.entries(board.tasks)) {
|
|
73
|
+
const taskPath = `tasks.${id}`;
|
|
74
|
+
|
|
75
|
+
if (!columnNames.includes(task.status)) {
|
|
76
|
+
errors.push({
|
|
77
|
+
path: `${taskPath}.status`,
|
|
78
|
+
message: `Task status "${task.status}" does not match any column. Valid columns: ${columnNames.join(", ")}`,
|
|
79
|
+
code: "STATUS_INVALID",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (task.depends_on) {
|
|
84
|
+
for (const refId of task.depends_on) {
|
|
85
|
+
if (!taskIds.has(refId)) {
|
|
86
|
+
errors.push({
|
|
87
|
+
path: `${taskPath}.depends_on`,
|
|
88
|
+
message: `Task "${id}" depends on "${refId}", which does not exist`,
|
|
89
|
+
code: "DEPENDS_ON_INVALID",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// WIP limit per column
|
|
97
|
+
const countByStatus = new Map<string, number>();
|
|
98
|
+
for (const task of Object.values(board.tasks)) {
|
|
99
|
+
countByStatus.set(task.status, (countByStatus.get(task.status) ?? 0) + 1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < board.columns.length; i++) {
|
|
103
|
+
const col = board.columns[i];
|
|
104
|
+
if (col.limit == null) continue;
|
|
105
|
+
const count = countByStatus.get(col.name) ?? 0;
|
|
106
|
+
if (count > col.limit) {
|
|
107
|
+
errors.push({
|
|
108
|
+
path: `columns[${i}]`,
|
|
109
|
+
message: `Column "${col.name}" has limit ${col.limit} but ${count} task(s) in that status`,
|
|
110
|
+
code: "WIP_LIMIT_EXCEEDED",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return errors;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Validate a parsed board. Returns a result with `valid` and `errors`; does not throw.
|
|
120
|
+
* Use this as the public API; use `validateBoard` if you only need the error list.
|
|
121
|
+
*
|
|
122
|
+
* @param board - Parsed board AST.
|
|
123
|
+
* @returns ValidationResult with `valid: true` and empty `errors` when valid, otherwise `valid: false` and non-empty `errors`.
|
|
124
|
+
*/
|
|
125
|
+
export function validate(board: Board): ValidationResult {
|
|
126
|
+
const errors = validateBoard(board);
|
|
127
|
+
return {
|
|
128
|
+
valid: errors.length === 0,
|
|
129
|
+
errors,
|
|
130
|
+
warnings: [],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
import { parseBoard, parseBoardFromString, ParseError } from "../src/parser.js";
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
const validBoardYaml = `
|
|
9
|
+
board: "Auth Service"
|
|
10
|
+
columns:
|
|
11
|
+
- Backlog
|
|
12
|
+
- Ready
|
|
13
|
+
- name: In Progress
|
|
14
|
+
limit: 3
|
|
15
|
+
- Done
|
|
16
|
+
tasks:
|
|
17
|
+
AUTH-7:
|
|
18
|
+
title: "Audit existing JWT usage"
|
|
19
|
+
status: Done
|
|
20
|
+
owner: kristijan
|
|
21
|
+
priority: high
|
|
22
|
+
description: "List all endpoints and call sites using JWT."
|
|
23
|
+
AUTH-12:
|
|
24
|
+
title: "Replace JWT with PASETO"
|
|
25
|
+
status: In Progress
|
|
26
|
+
owner: kristijan
|
|
27
|
+
priority: high
|
|
28
|
+
depends_on: AUTH-7
|
|
29
|
+
spec: tasks/AUTH-12.md
|
|
30
|
+
AUTH-13:
|
|
31
|
+
title: "Add rate limiting to auth endpoints"
|
|
32
|
+
status: Ready
|
|
33
|
+
priority: medium
|
|
34
|
+
description: "Per-IP and per-user limits; configurable thresholds."
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
describe("parseBoardFromString", () => {
|
|
38
|
+
it("parses valid YAML and returns typed Board", () => {
|
|
39
|
+
const board = parseBoardFromString(validBoardYaml);
|
|
40
|
+
|
|
41
|
+
expect(board.board).toBe("Auth Service");
|
|
42
|
+
expect(board.columns).toHaveLength(4);
|
|
43
|
+
expect(board.columns[0]).toEqual({ name: "Backlog" });
|
|
44
|
+
expect(board.columns[1]).toEqual({ name: "Ready" });
|
|
45
|
+
expect(board.columns[2]).toEqual({ name: "In Progress", limit: 3 });
|
|
46
|
+
expect(board.columns[3]).toEqual({ name: "Done" });
|
|
47
|
+
|
|
48
|
+
expect(Object.keys(board.tasks)).toEqual(["AUTH-7", "AUTH-12", "AUTH-13"]);
|
|
49
|
+
expect(board.tasks["AUTH-7"].title).toBe("Audit existing JWT usage");
|
|
50
|
+
expect(board.tasks["AUTH-7"].status).toBe("Done");
|
|
51
|
+
expect(board.tasks["AUTH-7"].description).toBe("List all endpoints and call sites using JWT.");
|
|
52
|
+
|
|
53
|
+
expect(board.tasks["AUTH-12"].title).toBe("Replace JWT with PASETO");
|
|
54
|
+
expect(board.tasks["AUTH-12"].status).toBe("In Progress");
|
|
55
|
+
expect(board.tasks["AUTH-12"].depends_on).toEqual(["AUTH-7"]);
|
|
56
|
+
expect(board.tasks["AUTH-12"].spec).toBe("tasks/AUTH-12.md");
|
|
57
|
+
|
|
58
|
+
expect(board.tasks["AUTH-13"].title).toBe("Add rate limiting to auth endpoints");
|
|
59
|
+
expect(board.tasks["AUTH-13"].status).toBe("Ready");
|
|
60
|
+
expect(board.tasks["AUTH-13"].priority).toBe("medium");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("normalizes depends_on single string to array", () => {
|
|
64
|
+
const yaml = `
|
|
65
|
+
board: "Test"
|
|
66
|
+
columns: [A, B]
|
|
67
|
+
tasks:
|
|
68
|
+
t1:
|
|
69
|
+
title: "One"
|
|
70
|
+
status: A
|
|
71
|
+
t2:
|
|
72
|
+
title: "Two"
|
|
73
|
+
status: B
|
|
74
|
+
depends_on: t1
|
|
75
|
+
`;
|
|
76
|
+
const board = parseBoardFromString(yaml);
|
|
77
|
+
expect(board.tasks["t2"].depends_on).toEqual(["t1"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("normalizes depends_on array as-is", () => {
|
|
81
|
+
const yaml = `
|
|
82
|
+
board: "Test"
|
|
83
|
+
columns: [A, B, C]
|
|
84
|
+
tasks:
|
|
85
|
+
t1: { title: "One", status: A }
|
|
86
|
+
t2: { title: "Two", status: B }
|
|
87
|
+
t3:
|
|
88
|
+
title: "Three"
|
|
89
|
+
status: C
|
|
90
|
+
depends_on: [t1, t2]
|
|
91
|
+
`;
|
|
92
|
+
const board = parseBoardFromString(yaml);
|
|
93
|
+
expect(board.tasks["t3"].depends_on).toEqual(["t1", "t2"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("parses minimal board (empty tasks)", () => {
|
|
97
|
+
const yaml = `
|
|
98
|
+
board: "Minimal"
|
|
99
|
+
columns: [Backlog, Ready, In Progress, Done]
|
|
100
|
+
tasks: {}
|
|
101
|
+
`;
|
|
102
|
+
const board = parseBoardFromString(yaml);
|
|
103
|
+
expect(board.board).toBe("Minimal");
|
|
104
|
+
expect(board.columns).toHaveLength(4);
|
|
105
|
+
expect(board.tasks).toEqual({});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("throws ParseError on invalid YAML", () => {
|
|
109
|
+
expect(() => parseBoardFromString("not: valid: yaml:")).toThrow(ParseError);
|
|
110
|
+
expect(() => parseBoardFromString("not: valid: yaml:")).toThrow(/Invalid YAML/);
|
|
111
|
+
|
|
112
|
+
expect(() => parseBoardFromString("[")).toThrow(ParseError);
|
|
113
|
+
expect(() => parseBoardFromString("[")).toThrow(/Invalid YAML/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("throws ParseError when board is missing", () => {
|
|
117
|
+
const yaml = `
|
|
118
|
+
columns: [A]
|
|
119
|
+
tasks: {}
|
|
120
|
+
`;
|
|
121
|
+
expect(() => parseBoardFromString(yaml)).toThrow(ParseError);
|
|
122
|
+
expect(() => parseBoardFromString(yaml)).toThrow(/Missing required field: board/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("throws ParseError when columns is missing", () => {
|
|
126
|
+
const yaml = `
|
|
127
|
+
board: "Test"
|
|
128
|
+
tasks: {}
|
|
129
|
+
`;
|
|
130
|
+
expect(() => parseBoardFromString(yaml)).toThrow(ParseError);
|
|
131
|
+
expect(() => parseBoardFromString(yaml)).toThrow(/Missing required field: columns/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("throws ParseError when tasks is missing", () => {
|
|
135
|
+
const yaml = `
|
|
136
|
+
board: "Test"
|
|
137
|
+
columns: [A]
|
|
138
|
+
`;
|
|
139
|
+
expect(() => parseBoardFromString(yaml)).toThrow(ParseError);
|
|
140
|
+
expect(() => parseBoardFromString(yaml)).toThrow(/Missing required field: tasks/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("throws ParseError when board is empty string", () => {
|
|
144
|
+
const yaml = `
|
|
145
|
+
board: ""
|
|
146
|
+
columns: [A]
|
|
147
|
+
tasks: {}
|
|
148
|
+
`;
|
|
149
|
+
expect(() => parseBoardFromString(yaml)).toThrow(ParseError);
|
|
150
|
+
expect(() => parseBoardFromString(yaml)).toThrow(/non-empty/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("throws ParseError when columns is not an array", () => {
|
|
154
|
+
const yaml = `
|
|
155
|
+
board: "Test"
|
|
156
|
+
columns: "not an array"
|
|
157
|
+
tasks: {}
|
|
158
|
+
`;
|
|
159
|
+
expect(() => parseBoardFromString(yaml)).toThrow(ParseError);
|
|
160
|
+
expect(() => parseBoardFromString(yaml)).toThrow(/expected an array/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("throws ParseError when columns is empty", () => {
|
|
164
|
+
const yaml = `
|
|
165
|
+
board: "Test"
|
|
166
|
+
columns: []
|
|
167
|
+
tasks: {}
|
|
168
|
+
`;
|
|
169
|
+
expect(() => parseBoardFromString(yaml)).toThrow(ParseError);
|
|
170
|
+
expect(() => parseBoardFromString(yaml)).toThrow(/non-empty array/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("throws ParseError when task is missing title", () => {
|
|
174
|
+
const yaml = `
|
|
175
|
+
board: "Test"
|
|
176
|
+
columns: [A]
|
|
177
|
+
tasks:
|
|
178
|
+
t1:
|
|
179
|
+
status: A
|
|
180
|
+
`;
|
|
181
|
+
expect(() => parseBoardFromString(yaml)).toThrow(ParseError);
|
|
182
|
+
expect(() => parseBoardFromString(yaml)).toThrow(/title/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("throws ParseError when task is missing status", () => {
|
|
186
|
+
const yaml = `
|
|
187
|
+
board: "Test"
|
|
188
|
+
columns: [A]
|
|
189
|
+
tasks:
|
|
190
|
+
t1:
|
|
191
|
+
title: "Task"
|
|
192
|
+
`;
|
|
193
|
+
expect(() => parseBoardFromString(yaml)).toThrow(ParseError);
|
|
194
|
+
expect(() => parseBoardFromString(yaml)).toThrow(/status/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("throws ParseError when column limit is not a positive integer", () => {
|
|
198
|
+
const yaml = `
|
|
199
|
+
board: "Test"
|
|
200
|
+
columns:
|
|
201
|
+
- name: In Progress
|
|
202
|
+
limit: "three"
|
|
203
|
+
tasks: {}
|
|
204
|
+
`;
|
|
205
|
+
expect(() => parseBoardFromString(yaml)).toThrow(ParseError);
|
|
206
|
+
expect(() => parseBoardFromString(yaml)).toThrow(/limit/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("throws ParseError when tasks is not an object", () => {
|
|
210
|
+
const yaml = `
|
|
211
|
+
board: "Test"
|
|
212
|
+
columns: [A]
|
|
213
|
+
tasks: []
|
|
214
|
+
`;
|
|
215
|
+
expect(() => parseBoardFromString(yaml)).toThrow(ParseError);
|
|
216
|
+
expect(() => parseBoardFromString(yaml)).toThrow(/tasks must be an object/);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("parseBoard (path or content)", () => {
|
|
221
|
+
it("parses raw YAML content when input contains newline", () => {
|
|
222
|
+
const yaml = `
|
|
223
|
+
board: "From Content"
|
|
224
|
+
columns: [A]
|
|
225
|
+
tasks: {}
|
|
226
|
+
`;
|
|
227
|
+
const board = parseBoard(yaml);
|
|
228
|
+
expect(board.board).toBe("From Content");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("parses from file path when input looks like path", () => {
|
|
232
|
+
const fixturePath = path.join(__dirname, "fixtures", "board.yaml");
|
|
233
|
+
const board = parseBoard(fixturePath);
|
|
234
|
+
expect(board.board).toBe("Fixture Board");
|
|
235
|
+
expect(board.columns).toHaveLength(4);
|
|
236
|
+
expect(board.columns.map((c) => (typeof c === "string" ? c : c.name))).toEqual([
|
|
237
|
+
"Backlog",
|
|
238
|
+
"Ready",
|
|
239
|
+
"In Progress",
|
|
240
|
+
"Done",
|
|
241
|
+
]);
|
|
242
|
+
expect(board.tasks["f1"].title).toBe("Fixture task");
|
|
243
|
+
expect(board.tasks["f1"].status).toBe("Backlog");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("throws ParseError when path does not exist", () => {
|
|
247
|
+
expect(() => parseBoard("/nonexistent/board.yaml")).toThrow(ParseError);
|
|
248
|
+
expect(() => parseBoard("/nonexistent/board.yaml")).toThrow(/Cannot read file/);
|
|
249
|
+
});
|
|
250
|
+
});
|