feat(scripts): state-builder reads depends-on + blocks from frontmatter

This commit is contained in:
2026-05-13 08:04:38 +02:00
parent adabb3428d
commit 23fedac1a8
3 changed files with 363 additions and 12 deletions

View File

@@ -2,7 +2,12 @@ import { describe, it, expect } from "vitest";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { buildState, parseFrontmatter, countTaskCheckboxes } from "./state-builder.mjs";
import {
buildState,
parseFrontmatter,
countTaskCheckboxes,
computeReadyBlocked,
} from "./state-builder.mjs";
function makeWorkTree({ epics }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-state-"));
@@ -81,8 +86,22 @@ describe("buildState", () => {
status: "in-progress",
title: "epic1",
stories: {
story1: { status: "done", title: "story1", ac_total: 2, ac_completed: 2 },
story2: { status: "todo", title: "story2", ac_total: 3, ac_completed: 0 },
story1: {
status: "done",
title: "story1",
ac_total: 2,
ac_completed: 2,
depends_on: [],
blocks: [],
},
story2: {
status: "todo",
title: "story2",
ac_total: 3,
ac_completed: 0,
depends_on: [],
blocks: [],
},
},
},
});
@@ -91,7 +110,10 @@ describe("buildState", () => {
it("skips _templates and prds folders", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-skip-"));
fs.mkdirSync(path.join(root, "_templates"), { recursive: true });
fs.writeFileSync(path.join(root, "_templates", "_epic.md"), "---\nid: x\n---");
fs.writeFileSync(
path.join(root, "_templates", "_epic.md"),
"---\nid: x\n---",
);
fs.mkdirSync(path.join(root, "prds"), { recursive: true });
fs.writeFileSync(path.join(root, "prds", "_epic.md"), "---\nid: y\n---");
expect(buildState(root).epics).toEqual({});
@@ -102,6 +124,56 @@ describe("buildState", () => {
fs.mkdirSync(path.join(root, "incomplete"), { recursive: true });
expect(buildState(root).epics).toEqual({});
});
it("parses depends-on and blocks frontmatter arrays", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-dep-"));
const epicDir = path.join(root, "epic1");
fs.mkdirSync(epicDir, { recursive: true });
fs.writeFileSync(
path.join(epicDir, "_epic.md"),
`---
id: epic1
title: epic1
status: todo
---
`,
);
const story1Dir = path.join(epicDir, "story1");
fs.mkdirSync(story1Dir, { recursive: true });
fs.writeFileSync(
path.join(story1Dir, "_story.md"),
`---
id: story1
title: story1
status: todo
depends-on: []
blocks: [story2, other-epic/x]
---
`,
);
const story2Dir = path.join(epicDir, "story2");
fs.mkdirSync(story2Dir, { recursive: true });
fs.writeFileSync(
path.join(story2Dir, "_story.md"),
`---
id: story2
title: story2
status: todo
depends-on: [story1]
blocks: []
---
`,
);
const state = buildState(root);
expect(state.epics.epic1.stories.story1.depends_on).toEqual([]);
expect(state.epics.epic1.stories.story1.blocks).toEqual([
"story2",
"other-epic/x",
]);
expect(state.epics.epic1.stories.story2.depends_on).toEqual(["story1"]);
expect(state.epics.epic1.stories.story2.blocks).toEqual([]);
});
});
describe("countTaskCheckboxes", () => {
@@ -125,7 +197,10 @@ describe("countTaskCheckboxes", () => {
});
it("returns zeros when no Tasks section exists", () => {
expect(countTaskCheckboxes(`## Goal\n\nFoo\n`)).toEqual({ total: 0, completed: 0 });
expect(countTaskCheckboxes(`## Goal\n\nFoo\n`)).toEqual({
total: 0,
completed: 0,
});
});
});
@@ -133,14 +208,21 @@ describe("parseFrontmatter", () => {
it("extracts simple string keys", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm-"));
const fp = path.join(dir, "f.md");
fs.writeFileSync(fp, `---
fs.writeFileSync(
fp,
`---
id: x
title: A title
status: done
---
Body`);
expect(parseFrontmatter(fp)).toEqual({ id: "x", title: "A title", status: "done" });
Body`,
);
expect(parseFrontmatter(fp)).toEqual({
id: "x",
title: "A title",
status: "done",
});
});
it("returns {} when no frontmatter present", () => {
@@ -149,4 +231,157 @@ Body`);
fs.writeFileSync(fp, "# Just a heading\n");
expect(parseFrontmatter(fp)).toEqual({});
});
it("parses array frontmatter values", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm-arr-"));
const fp = path.join(dir, "f.md");
fs.writeFileSync(
fp,
`---
id: x
items: [a, b, "c"]
empty: []
---
`,
);
expect(parseFrontmatter(fp)).toEqual({
id: "x",
items: ["a", "b", "c"],
empty: [],
});
});
});
describe("computeReadyBlocked", () => {
it("returns ready for stories whose depends_on are all done", () => {
const state = {
updated_at: "x",
epics: {
e: {
status: "in-progress",
title: "e",
stories: {
a: {
status: "done",
title: "a",
ac_total: 0,
ac_completed: 0,
depends_on: [],
blocks: [],
},
b: {
status: "todo",
title: "b",
ac_total: 0,
ac_completed: 0,
depends_on: ["a"],
blocks: [],
},
},
},
},
};
const { ready, blocked } = computeReadyBlocked(state);
expect(ready).toEqual([{ epic: "e", story: "b", title: "b" }]);
expect(blocked).toEqual([]);
});
it("returns blocked for stories whose depends_on are not done", () => {
const state = {
updated_at: "x",
epics: {
e: {
status: "in-progress",
title: "e",
stories: {
a: {
status: "todo",
title: "a",
ac_total: 0,
ac_completed: 0,
depends_on: [],
blocks: [],
},
b: {
status: "todo",
title: "b",
ac_total: 0,
ac_completed: 0,
depends_on: ["a"],
blocks: [],
},
},
},
},
};
const { ready, blocked } = computeReadyBlocked(state);
expect(ready).toEqual([{ epic: "e", story: "a", title: "a" }]);
expect(blocked).toEqual([
{ epic: "e", story: "b", title: "b", waiting_on: ["e/a"] },
]);
});
it("resolves cross-epic refs", () => {
const state = {
updated_at: "x",
epics: {
e1: {
status: "done",
title: "e1",
stories: {
a: {
status: "done",
title: "a",
ac_total: 0,
ac_completed: 0,
depends_on: [],
blocks: [],
},
},
},
e2: {
status: "in-progress",
title: "e2",
stories: {
b: {
status: "todo",
title: "b",
ac_total: 0,
ac_completed: 0,
depends_on: ["e1/a"],
blocks: [],
},
},
},
},
};
const { ready, blocked } = computeReadyBlocked(state);
expect(ready).toEqual([{ epic: "e2", story: "b", title: "b" }]);
expect(blocked).toEqual([]);
});
it("skips done stories", () => {
const state = {
updated_at: "x",
epics: {
e: {
status: "done",
title: "e",
stories: {
a: {
status: "done",
title: "a",
ac_total: 1,
ac_completed: 1,
depends_on: [],
blocks: [],
},
},
},
},
};
const { ready, blocked } = computeReadyBlocked(state);
expect(ready).toEqual([]);
expect(blocked).toEqual([]);
});
});