119 lines
3.5 KiB
JavaScript
119 lines
3.5 KiB
JavaScript
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 };
|
|
}
|