From 6b57d76dc22d75186ceff5dd7fa9ed32a1abc221 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 13 May 2026 07:46:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(scripts):=20work=20state-builder=20?= =?UTF-8?q?=E2=80=94=20walks=20docs/work/=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/work/state-builder.mjs | 118 +++++++++++++++++++++ scripts/work/state-builder.test.mjs | 152 ++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 scripts/work/state-builder.mjs create mode 100644 scripts/work/state-builder.test.mjs diff --git a/scripts/work/state-builder.mjs b/scripts/work/state-builder.mjs new file mode 100644 index 0000000..5a7f42b --- /dev/null +++ b/scripts/work/state-builder.mjs @@ -0,0 +1,118 @@ +import fs from "node:fs"; +import path from "node:path"; + +const SKIP_FOLDERS = new Set(["_templates", "prds"]); +const SKIP_FILES = new Set(["README.md", "_state.json"]); + +/** + * Walk the `docs/work/` tree starting at `workRoot` and return a structured + * state object. Each epic folder must contain `_epic.md`; each story + * subfolder must contain `_story.md`. + * + * Returns: + * { + * updated_at: ISO string, + * epics: { + * : { + * status: "todo" | "in-progress" | "done", + * title: string, + * stories: { + * : { + * status: "todo" | "in-progress" | "done", + * title: string, + * ac_total: number, // total checkboxes in Tasks section + * ac_completed: number, // - [x] checkboxes + * } + * } + * } + * } + * } + */ +export function buildState(workRoot) { + const state = { + updated_at: new Date().toISOString(), + epics: {}, + }; + + if (!fs.existsSync(workRoot)) return state; + + for (const entry of fs.readdirSync(workRoot)) { + if (SKIP_FOLDERS.has(entry) || SKIP_FILES.has(entry)) continue; + const epicDir = path.join(workRoot, entry); + if (!fs.statSync(epicDir).isDirectory()) continue; + const epicFile = path.join(epicDir, "_epic.md"); + if (!fs.existsSync(epicFile)) continue; + + const epicMeta = parseFrontmatter(epicFile); + const epicEntry = { + status: epicMeta.status ?? "todo", + title: epicMeta.title ?? entry, + stories: {}, + }; + + for (const sub of fs.readdirSync(epicDir)) { + const subPath = path.join(epicDir, sub); + if (!fs.statSync(subPath).isDirectory()) continue; + const storyFile = path.join(subPath, "_story.md"); + if (!fs.existsSync(storyFile)) continue; + const storyMeta = parseFrontmatter(storyFile); + const storyContent = fs.readFileSync(storyFile, "utf8"); + const { total, completed } = countTaskCheckboxes(storyContent); + epicEntry.stories[storyMeta.id ?? sub] = { + status: storyMeta.status ?? "todo", + title: storyMeta.title ?? sub, + ac_total: total, + ac_completed: completed, + }; + } + + state.epics[epicMeta.id ?? entry] = epicEntry; + } + + return state; +} + +/** + * 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). + */ +export function parseFrontmatter(filePath) { + const src = fs.readFileSync(filePath, "utf8"); + const match = src.match(/^---\n([\s\S]+?)\n---/); + if (!match) return {}; + const out = {}; + for (const line of match[1].split("\n")) { + 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); + out[m[1]] = value; + } + return out; +} + +/** + * Count `- [x]` and `- [ ]` checkboxes inside the file's `## Tasks` section. + * Returns { total, completed }. + */ +export function countTaskCheckboxes(content) { + const lines = content.split("\n"); + let inTasks = false; + let total = 0; + let completed = 0; + for (const line of lines) { + if (line.startsWith("## ")) { + inTasks = /^##\s+Tasks\b/i.test(line); + continue; + } + if (!inTasks) continue; + const m = line.match(/^[\s>-]*\[(.)\]/); + if (m) { + total++; + if (m[1] === "x" || m[1] === "X") completed++; + } + } + return { total, completed }; +} diff --git a/scripts/work/state-builder.test.mjs b/scripts/work/state-builder.test.mjs new file mode 100644 index 0000000..960a4fd --- /dev/null +++ b/scripts/work/state-builder.test.mjs @@ -0,0 +1,152 @@ +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"; + +function makeWorkTree({ epics }) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-state-")); + for (const [epicId, epicData] of Object.entries(epics)) { + const epicDir = path.join(root, epicId); + fs.mkdirSync(epicDir, { recursive: true }); + fs.writeFileSync( + path.join(epicDir, "_epic.md"), + `--- +id: ${epicId} +title: ${epicData.title ?? epicId} +status: ${epicData.status ?? "todo"} +--- + +`, + ); + for (const [storyId, storyData] of Object.entries(epicData.stories ?? {})) { + const storyDir = path.join(epicDir, storyId); + fs.mkdirSync(storyDir, { recursive: true }); + const tasksBlock = (storyData.tasks ?? []) + .map((t) => `- [${t.done ? "x" : " "}] ${t.label}`) + .join("\n"); + fs.writeFileSync( + path.join(storyDir, "_story.md"), + `--- +id: ${storyId} +title: ${storyData.title ?? storyId} +status: ${storyData.status ?? "todo"} +--- + +## Tasks +${tasksBlock} +`, + ); + } + } + return root; +} + +describe("buildState", () => { + it("returns an empty epics map for a non-existent workRoot", () => { + const state = buildState("/nonexistent/path"); + expect(state.epics).toEqual({}); + expect(typeof state.updated_at).toBe("string"); + }); + + it("collects epics + stories with status + task counts", () => { + const root = makeWorkTree({ + epics: { + epic1: { + status: "in-progress", + stories: { + story1: { + status: "done", + tasks: [ + { label: "a", done: true }, + { label: "b", done: true }, + ], + }, + story2: { + status: "todo", + tasks: [ + { label: "c", done: false }, + { label: "d", done: false }, + { label: "e", done: false }, + ], + }, + }, + }, + }, + }); + + const state = buildState(root); + expect(state.epics).toEqual({ + epic1: { + 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 }, + }, + }, + }); + }); + + 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.mkdirSync(path.join(root, "prds"), { recursive: true }); + fs.writeFileSync(path.join(root, "prds", "_epic.md"), "---\nid: y\n---"); + expect(buildState(root).epics).toEqual({}); + }); + + it("skips directories without _epic.md", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-skip2-")); + fs.mkdirSync(path.join(root, "incomplete"), { recursive: true }); + expect(buildState(root).epics).toEqual({}); + }); +}); + +describe("countTaskCheckboxes", () => { + it("counts both unchecked and checked", () => { + const content = `## Tasks +- [x] done one +- [ ] open one +- [X] capital X also counts +`; + expect(countTaskCheckboxes(content)).toEqual({ total: 3, completed: 2 }); + }); + + it("only counts inside the ## Tasks section", () => { + const content = `## Tasks +- [x] in tasks + +## Notes +- [x] outside, should not count +`; + expect(countTaskCheckboxes(content)).toEqual({ total: 1, completed: 1 }); + }); + + it("returns zeros when no Tasks section exists", () => { + expect(countTaskCheckboxes(`## Goal\n\nFoo\n`)).toEqual({ total: 0, completed: 0 }); + }); +}); + +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, `--- +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", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm2-")); + const fp = path.join(dir, "f.md"); + fs.writeFileSync(fp, "# Just a heading\n"); + expect(parseFrontmatter(fp)).toEqual({}); + }); +});