@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseBoardFromString } from "../src/parser.js";
|
|
3
|
+
import { summarize } from "../src/summarize.js";
|
|
4
|
+
|
|
5
|
+
describe("summarize", () => {
|
|
6
|
+
it("includes board name, column counts, and task lines", () => {
|
|
7
|
+
const yaml = `
|
|
8
|
+
board: "Summary Test"
|
|
9
|
+
columns: [Backlog, Ready, In Progress, Done]
|
|
10
|
+
tasks:
|
|
11
|
+
T1:
|
|
12
|
+
title: "First task"
|
|
13
|
+
status: Backlog
|
|
14
|
+
T2:
|
|
15
|
+
title: "Second task"
|
|
16
|
+
status: Done
|
|
17
|
+
`;
|
|
18
|
+
const board = parseBoardFromString(yaml);
|
|
19
|
+
const out = summarize(board);
|
|
20
|
+
expect(out).toContain("Board: Summary Test");
|
|
21
|
+
expect(out).toContain("Columns:");
|
|
22
|
+
expect(out).toContain("Backlog");
|
|
23
|
+
expect(out).toContain("Done");
|
|
24
|
+
expect(out).toContain("Tasks:");
|
|
25
|
+
expect(out).toContain("T1");
|
|
26
|
+
expect(out).toContain("T2");
|
|
27
|
+
expect(out).toContain("First task");
|
|
28
|
+
expect(out).toContain("Second task");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("ends with a single newline", () => {
|
|
32
|
+
const yaml = `
|
|
33
|
+
board: "X"
|
|
34
|
+
columns: [Backlog, Ready, In Progress, Done]
|
|
35
|
+
tasks: {}
|
|
36
|
+
`;
|
|
37
|
+
const board = parseBoardFromString(yaml);
|
|
38
|
+
const out = summarize(board);
|
|
39
|
+
expect(out).toMatch(/\n$/);
|
|
40
|
+
expect(out).not.toMatch(/\n\n$/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("includes WIP limit line when column is at limit", () => {
|
|
44
|
+
const yaml = `
|
|
45
|
+
board: "WIP"
|
|
46
|
+
columns:
|
|
47
|
+
- Backlog
|
|
48
|
+
- Ready
|
|
49
|
+
- name: In Progress
|
|
50
|
+
limit: 2
|
|
51
|
+
- Done
|
|
52
|
+
tasks:
|
|
53
|
+
t1: { title: "A", status: In Progress }
|
|
54
|
+
t2: { title: "B", status: In Progress }
|
|
55
|
+
`;
|
|
56
|
+
const board = parseBoardFromString(yaml);
|
|
57
|
+
const out = summarize(board);
|
|
58
|
+
expect(out).toContain("at WIP limit");
|
|
59
|
+
expect(out).toContain("2/2");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("includes Blocked section when a task depends on non-done task", () => {
|
|
63
|
+
const yaml = `
|
|
64
|
+
board: "Blocked"
|
|
65
|
+
columns: [Backlog, Ready, In Progress, Done]
|
|
66
|
+
tasks:
|
|
67
|
+
t1: { title: "First", status: Backlog }
|
|
68
|
+
t2: { title: "Second", status: Backlog, depends_on: [t1] }
|
|
69
|
+
`;
|
|
70
|
+
const board = parseBoardFromString(yaml);
|
|
71
|
+
const out = summarize(board);
|
|
72
|
+
expect(out).toContain("Blocked:");
|
|
73
|
+
expect(out).toContain("t2");
|
|
74
|
+
expect(out).toContain("depends on");
|
|
75
|
+
expect(out).toContain("t1");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("omits Blocked section when all dependencies are in last column", () => {
|
|
79
|
+
const yaml = `
|
|
80
|
+
board: "Unblocked"
|
|
81
|
+
columns: [Backlog, Ready, In Progress, Done]
|
|
82
|
+
tasks:
|
|
83
|
+
t1: { title: "First", status: Done }
|
|
84
|
+
t2: { title: "Second", status: Backlog, depends_on: [t1] }
|
|
85
|
+
`;
|
|
86
|
+
const board = parseBoardFromString(yaml);
|
|
87
|
+
const out = summarize(board);
|
|
88
|
+
expect(out).not.toContain("Blocked:");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("shows (none) for empty tasks", () => {
|
|
92
|
+
const yaml = `
|
|
93
|
+
board: "Empty"
|
|
94
|
+
columns: [Backlog, Ready, In Progress, Done]
|
|
95
|
+
tasks: {}
|
|
96
|
+
`;
|
|
97
|
+
const board = parseBoardFromString(yaml);
|
|
98
|
+
const out = summarize(board);
|
|
99
|
+
expect(out).toContain("Board: Empty");
|
|
100
|
+
expect(out).toContain("Tasks:");
|
|
101
|
+
expect(out).toContain("(none)");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseBoardFromString } from "../src/parser.js";
|
|
3
|
+
import { validate } from "../src/validation.js";
|
|
4
|
+
|
|
5
|
+
const validBoardYaml = `
|
|
6
|
+
board: "Valid"
|
|
7
|
+
columns: [Backlog, Ready, In Progress, Done]
|
|
8
|
+
tasks:
|
|
9
|
+
t1: { title: "One", status: Backlog }
|
|
10
|
+
t2: { title: "Two", status: Ready }
|
|
11
|
+
t3: { title: "Three", status: In Progress }
|
|
12
|
+
t4: { title: "Four", status: Done }
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
describe("validate", () => {
|
|
16
|
+
it("returns valid and empty errors for a board with canonical columns", () => {
|
|
17
|
+
const board = parseBoardFromString(validBoardYaml);
|
|
18
|
+
const result = validate(board);
|
|
19
|
+
expect(result.valid).toBe(true);
|
|
20
|
+
expect(result.errors).toHaveLength(0);
|
|
21
|
+
expect(result.warnings).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns error when columns do not match canonical set", () => {
|
|
25
|
+
const yaml = `
|
|
26
|
+
board: "Test"
|
|
27
|
+
columns: [To Do, Done]
|
|
28
|
+
tasks:
|
|
29
|
+
t1: { title: "Task", status: "To Do" }
|
|
30
|
+
`;
|
|
31
|
+
const board = parseBoardFromString(yaml);
|
|
32
|
+
const result = validate(board);
|
|
33
|
+
expect(result.valid).toBe(false);
|
|
34
|
+
const canonicalError = result.errors.find((e) => e.code === "COLUMNS_NOT_CANONICAL");
|
|
35
|
+
expect(canonicalError).toBeDefined();
|
|
36
|
+
expect(canonicalError!.message).toMatch(/Backlog.*Ready.*In Progress.*Done/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns error when task status does not match any column", () => {
|
|
40
|
+
const yaml = `
|
|
41
|
+
board: "Test"
|
|
42
|
+
columns: [Backlog, Ready, In Progress, Done]
|
|
43
|
+
tasks:
|
|
44
|
+
t1:
|
|
45
|
+
title: "Task"
|
|
46
|
+
status: Unknown Column
|
|
47
|
+
`;
|
|
48
|
+
const board = parseBoardFromString(yaml);
|
|
49
|
+
const result = validate(board);
|
|
50
|
+
expect(result.valid).toBe(false);
|
|
51
|
+
expect(result.errors.length).toBeGreaterThanOrEqual(1);
|
|
52
|
+
const statusError = result.errors.find((e) => e.code === "STATUS_INVALID");
|
|
53
|
+
expect(statusError).toBeDefined();
|
|
54
|
+
expect(statusError!.message).toMatch(/Unknown Column/);
|
|
55
|
+
expect(statusError!.path).toBe("tasks.t1.status");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns error when depends_on references non-existent task", () => {
|
|
59
|
+
const yaml = `
|
|
60
|
+
board: "Test"
|
|
61
|
+
columns: [Backlog, Ready, In Progress, Done]
|
|
62
|
+
tasks:
|
|
63
|
+
t1: { title: "One", status: Backlog }
|
|
64
|
+
t2:
|
|
65
|
+
title: "Two"
|
|
66
|
+
status: Done
|
|
67
|
+
depends_on: [NONEXISTENT]
|
|
68
|
+
`;
|
|
69
|
+
const board = parseBoardFromString(yaml);
|
|
70
|
+
const result = validate(board);
|
|
71
|
+
expect(result.valid).toBe(false);
|
|
72
|
+
const refError = result.errors.find((e) => e.code === "DEPENDS_ON_INVALID");
|
|
73
|
+
expect(refError).toBeDefined();
|
|
74
|
+
expect(refError!.message).toMatch(/NONEXISTENT/);
|
|
75
|
+
expect(refError!.path).toBe("tasks.t2.depends_on");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns error when WIP limit exceeded", () => {
|
|
79
|
+
const yaml = `
|
|
80
|
+
board: "Test"
|
|
81
|
+
columns:
|
|
82
|
+
- Backlog
|
|
83
|
+
- Ready
|
|
84
|
+
- name: In Progress
|
|
85
|
+
limit: 1
|
|
86
|
+
- Done
|
|
87
|
+
tasks:
|
|
88
|
+
t1: { title: "One", status: In Progress }
|
|
89
|
+
t2: { title: "Two", status: In Progress }
|
|
90
|
+
`;
|
|
91
|
+
const board = parseBoardFromString(yaml);
|
|
92
|
+
const result = validate(board);
|
|
93
|
+
expect(result.valid).toBe(false);
|
|
94
|
+
const wipError = result.errors.find((e) => e.code === "WIP_LIMIT_EXCEEDED");
|
|
95
|
+
expect(wipError).toBeDefined();
|
|
96
|
+
expect(wipError!.message).toMatch(/limit 1.*2 task/);
|
|
97
|
+
expect(wipError!.path).toMatch(/columns/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns error when column names are duplicated", () => {
|
|
101
|
+
const yaml = `
|
|
102
|
+
board: "Test"
|
|
103
|
+
columns: [Backlog, Backlog, In Progress, Done]
|
|
104
|
+
tasks: {}
|
|
105
|
+
`;
|
|
106
|
+
const board = parseBoardFromString(yaml);
|
|
107
|
+
const result = validate(board);
|
|
108
|
+
expect(result.valid).toBe(false);
|
|
109
|
+
const dupError = result.errors.find((e) => e.code === "DUPLICATE_COLUMN");
|
|
110
|
+
const canonicalError = result.errors.find((e) => e.code === "COLUMNS_NOT_CANONICAL");
|
|
111
|
+
expect(dupError ?? canonicalError).toBeDefined();
|
|
112
|
+
if (dupError) {
|
|
113
|
+
expect(dupError.message).toMatch(/Duplicate column name.*"Backlog"/);
|
|
114
|
+
expect(dupError.path).toBe("columns[1]");
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("each error has message and optional path/code", () => {
|
|
119
|
+
const yaml = `
|
|
120
|
+
board: "Test"
|
|
121
|
+
columns: [Backlog, Ready, In Progress, Done]
|
|
122
|
+
tasks:
|
|
123
|
+
t1: { title: "One", status: Bad }
|
|
124
|
+
`;
|
|
125
|
+
const board = parseBoardFromString(yaml);
|
|
126
|
+
const result = validate(board);
|
|
127
|
+
expect(result.errors.length).toBeGreaterThanOrEqual(1);
|
|
128
|
+
for (const err of result.errors) {
|
|
129
|
+
expect(err).toHaveProperty("message");
|
|
130
|
+
expect(typeof err.message).toBe("string");
|
|
131
|
+
expect(err.message.length).toBeGreaterThan(0);
|
|
132
|
+
if (err.path !== undefined) expect(typeof err.path).toBe("string");
|
|
133
|
+
if (err.code !== undefined) expect(typeof err.code).toBe("string");
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("multiple violations produce multiple errors", () => {
|
|
138
|
+
const yaml = `
|
|
139
|
+
board: "Test"
|
|
140
|
+
columns: [Backlog, Ready, In Progress, Done]
|
|
141
|
+
tasks:
|
|
142
|
+
t1: { title: "One", status: Unknown }
|
|
143
|
+
t2: { title: "Two", status: Backlog, depends_on: [MISSING] }
|
|
144
|
+
`;
|
|
145
|
+
const board = parseBoardFromString(yaml);
|
|
146
|
+
const result = validate(board);
|
|
147
|
+
expect(result.valid).toBe(false);
|
|
148
|
+
expect(result.errors.length).toBeGreaterThanOrEqual(2);
|
|
149
|
+
const codes = result.errors.map((e) => e.code);
|
|
150
|
+
expect(codes).toContain("STATUS_INVALID");
|
|
151
|
+
expect(codes).toContain("DEPENDS_ON_INVALID");
|
|
152
|
+
});
|
|
153
|
+
});
|
package/tsconfig.json
ADDED
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { defineConfig } from "tsup";
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8")) as { version: string };
|
|
9
|
+
|
|
10
|
+
export default defineConfig({
|
|
11
|
+
entry: ["src/index.ts"],
|
|
12
|
+
format: ["esm"],
|
|
13
|
+
dts: true,
|
|
14
|
+
sourcemap: true,
|
|
15
|
+
clean: true,
|
|
16
|
+
esbuildOptions(options) {
|
|
17
|
+
options.define = { ...options.define, __STATECRAFT_CORE_VERSION__: JSON.stringify(pkg.version) };
|
|
18
|
+
},
|
|
19
|
+
});
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
test: {
|
|
6
|
+
include: ["test/**/*.test.ts"],
|
|
7
|
+
globals: false,
|
|
8
|
+
coverage: {
|
|
9
|
+
provider: "v8",
|
|
10
|
+
reporter: ["text", "text-summary", "html"],
|
|
11
|
+
include: ["src/**/*.ts"],
|
|
12
|
+
exclude: ["src/**/*.test.ts", "test/**", "node_modules", "dist"],
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
resolve: {
|
|
16
|
+
alias: {
|
|
17
|
+
"@": resolve(__dirname, "./src"),
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|