From 23fedac1a8b76efcc489c4b410024a4ff026060e Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 13 May 2026 08:04:38 +0200 Subject: [PATCH] feat(scripts): state-builder reads depends-on + blocks from frontmatter --- eslint.config.js | 33 ++++ scripts/work/state-builder.mjs | 91 +++++++++- scripts/work/state-builder.test.mjs | 251 +++++++++++++++++++++++++++- 3 files changed, 363 insertions(+), 12 deletions(-) create mode 100644 eslint.config.js diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..bfe4305 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,33 @@ +/** + * Root ESLint config — covers scripts/ and any other plain-JS files at the + * repo root. Package-level configs (packages/*, apps/*) define their own + * eslint.config.js and take precedence for files inside those directories. + */ +import js from "@eslint/js"; + +// Minimal Node.js globals for scripts — avoids a peer-dep on `globals`. +const nodeGlobals = { + process: "readonly", + console: "readonly", + URL: "readonly", + Buffer: "readonly", + __dirname: "readonly", + __filename: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", +}; + +export default [ + { ignores: ["node_modules/**", "packages/**", "apps/**", "turbo/**"] }, + js.configs.recommended, + { + files: ["scripts/**/*.{mjs,cjs,js}"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: nodeGlobals, + }, + }, +]; diff --git a/scripts/work/state-builder.mjs b/scripts/work/state-builder.mjs index 5a7f42b..5c5158f 100644 --- a/scripts/work/state-builder.mjs +++ b/scripts/work/state-builder.mjs @@ -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 + * (`/`). + * + * Returns { ready: [...], blocked: [...] } where each entry is: + * { epic, story, title, [waiting_on?] } + */ +export function computeReadyBlocked(state) { + // Build a flat map: "/" => { 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 `/` or just ``) + // 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; diff --git a/scripts/work/state-builder.test.mjs b/scripts/work/state-builder.test.mjs index 960a4fd..e3dad9d 100644 --- a/scripts/work/state-builder.test.mjs +++ b/scripts/work/state-builder.test.mjs @@ -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([]); + }); });