feat(scripts): state-builder reads depends-on + blocks from frontmatter

This commit is contained in:
2026-05-13 08:04:38 +02:00
parent adabb3428d
commit 23fedac1a8
3 changed files with 363 additions and 12 deletions

View File

@@ -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;