feat(scripts): state-builder reads depends-on + blocks from frontmatter
This commit is contained in:
@@ -63,19 +63,84 @@ export function buildState(workRoot) {
|
||||
title: storyMeta.title ?? sub,
|
||||
ac_total: total,
|
||||
ac_completed: completed,
|
||||
depends_on: Array.isArray(storyMeta["depends-on"])
|
||||
? storyMeta["depends-on"]
|
||||
: [],
|
||||
blocks: Array.isArray(storyMeta.blocks) ? storyMeta.blocks : [],
|
||||
};
|
||||
}
|
||||
|
||||
state.epics[epicMeta.id ?? entry] = epicEntry;
|
||||
}
|
||||
|
||||
const { ready, blocked } = computeReadyBlocked(state);
|
||||
state.ready = ready;
|
||||
state.blocked = blocked;
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a built state object, compute the ready + blocked story sets.
|
||||
*
|
||||
* "ready" = not done AND all depends_on stories are done
|
||||
* "blocked" = not done AND at least one depends_on story is not done
|
||||
*
|
||||
* depends_on references are either same-epic (just story id) or cross-epic
|
||||
* (`<epic>/<story>`).
|
||||
*
|
||||
* Returns { ready: [...], blocked: [...] } where each entry is:
|
||||
* { epic, story, title, [waiting_on?] }
|
||||
*/
|
||||
export function computeReadyBlocked(state) {
|
||||
// Build a flat map: "<epic>/<story>" => { status, title, depends_on }
|
||||
const flat = new Map();
|
||||
for (const [epic, epicEntry] of Object.entries(state.epics)) {
|
||||
for (const [story, storyEntry] of Object.entries(epicEntry.stories)) {
|
||||
flat.set(`${epic}/${story}`, {
|
||||
epic,
|
||||
story,
|
||||
status: storyEntry.status,
|
||||
title: storyEntry.title,
|
||||
depends_on: storyEntry.depends_on,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a depends_on reference (which may be `<epic>/<story>` or just `<story>`)
|
||||
// relative to the current epic.
|
||||
function resolveRef(ref, currentEpic) {
|
||||
if (ref.includes("/")) return ref;
|
||||
return `${currentEpic}/${ref}`;
|
||||
}
|
||||
|
||||
const ready = [];
|
||||
const blocked = [];
|
||||
for (const entry of flat.values()) {
|
||||
if (entry.status === "done") continue;
|
||||
const refs = entry.depends_on.map((r) => resolveRef(r, entry.epic));
|
||||
const waitingOn = refs.filter((r) => {
|
||||
const dep = flat.get(r);
|
||||
return !dep || dep.status !== "done";
|
||||
});
|
||||
if (waitingOn.length === 0) {
|
||||
ready.push({ epic: entry.epic, story: entry.story, title: entry.title });
|
||||
} else {
|
||||
blocked.push({
|
||||
epic: entry.epic,
|
||||
story: entry.story,
|
||||
title: entry.title,
|
||||
waiting_on: waitingOn,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ready, blocked };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a markdown file's YAML frontmatter (between leading `---` delimiters)
|
||||
* and return a flat object of string-valued keys. Numeric / bool values are
|
||||
* left as strings; this is enough for our needs (status, id, title fields).
|
||||
* and return a flat object of string or array values. Numeric / bool values are
|
||||
* left as strings; array values like `[a, b, "c"]` are parsed into JS arrays.
|
||||
*/
|
||||
export function parseFrontmatter(filePath) {
|
||||
const src = fs.readFileSync(filePath, "utf8");
|
||||
@@ -86,8 +151,26 @@ export function parseFrontmatter(filePath) {
|
||||
const m = line.match(/^([\w-]+):\s*(.*)$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
|
||||
if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
|
||||
// Array values like [a, b, "c"] or []
|
||||
if (value.startsWith("[") && value.endsWith("]")) {
|
||||
const inner = value.slice(1, -1).trim();
|
||||
if (inner === "") {
|
||||
out[m[1]] = [];
|
||||
} else {
|
||||
out[m[1]] = inner.split(",").map((s) => {
|
||||
let v = s.trim();
|
||||
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
||||
if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1);
|
||||
return v;
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Strings (with optional quotes)
|
||||
if (value.startsWith('"') && value.endsWith('"'))
|
||||
value = value.slice(1, -1);
|
||||
if (value.startsWith("'") && value.endsWith("'"))
|
||||
value = value.slice(1, -1);
|
||||
out[m[1]] = value;
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user