feat(scripts): work state-builder — walks docs/work/ tree
This commit is contained in:
118
scripts/work/state-builder.mjs
Normal file
118
scripts/work/state-builder.mjs
Normal file
@@ -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: {
|
||||
* <epic-id>: {
|
||||
* status: "todo" | "in-progress" | "done",
|
||||
* title: string,
|
||||
* stories: {
|
||||
* <story-id>: {
|
||||
* 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 };
|
||||
}
|
||||
152
scripts/work/state-builder.test.mjs
Normal file
152
scripts/work/state-builder.test.mjs
Normal file
@@ -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({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user