Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ An epic's or milestone's `Required` also holds back every quest under it.
A child that must precede a sibling is in that sibling's `Required`.
Nothing else orders work within a milestone: `quest unblocked` ranks it by downstream impact, then path.
When the section is empty, delete it; the quest is now unblocked.
`Required` must be acyclic.
`Required` must be acyclic, and never link to a later milestone than the document listing it: move one side with `quest mv` or remove the link.

`quest check` enforces this structure.
Run it after creating or updating quests.
Expand Down
2 changes: 1 addition & 1 deletion assets/skills/audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The scope is every outstanding quest, or the quests and epics the argument names
Look for:
- Disagreements: quests whose goals or plans contradict each other, the code, or the repository's docs.
- Conflicts: quests that would change the same code or interface incompatibly, or that must land in order without a `Required` link.
- Misaligned priorities: a quest placed in a milestone ahead of work it depends on, or behind work it blocks.
- Misaligned priorities: a quest whose milestone no longer matches its urgency, or that depends on later-milestone work without a `Required` link saying so (`quest check` already rejects a `Required` link to a later milestone).
- Stale plans: work already done, blockers that have cleared, or references to code that no longer exists.
- Obsolete quests: a problem that is no longer real, or a plan a later change or decision has made the wrong solution.

Expand Down
62 changes: 0 additions & 62 deletions quest/m0/milestone-order.md

This file was deleted.

44 changes: 44 additions & 0 deletions src/core/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export function check(docs: Doc[], exists: (path: string) => boolean): Finding[]
collisions(found, known, docs);
membership(found, known, docs);
milestones(found, docs);
inversions(found, docs);
cycles(found, docs);

return found.sort(compareFindings);
Expand Down Expand Up @@ -339,6 +340,49 @@ export function finishedMilestone(docs: Doc[]): (doc: Doc, entry: Entry) => bool
};
}

/**
* The milestone README of the root child directory holding `path`, or `null`
* for the root README and files directly under the root.
*/
function milestoneOf(path: string): string | null {
const parts = path.split("/");
return parts.length >= 3 ? `${parts[0]}/${parts[1]}/README.md` : null;
}

/**
* A `Required` link must not point to a later milestone. `quest unblocked`
* ranks a blocker by its own milestone, so an inverted link stalls the earlier
* quest behind all of the later milestone's work. Which side moves is the
* maintainer's priority call, so there is no fix. A milestone README cannot
* move, since milestones are permanent.
*/
function inversions(found: Finding[], docs: Doc[]) {
for (const doc of docs) {
const from = milestoneOf(doc.path);
if (from === null) continue;
for (const entry of entries(doc, "Required")) {
if (entry.target === null) continue;
const target = rooted(entry.target);
if (target === null) continue;
const to = milestoneOf(target);
if (to === null || comparePaths(to, from) <= 0) continue;
const [early, late] = [from.split("/")[1], to.split("/")[1]];
const name = (path: string) => path.replace(/\/README\.md$/, "");
const moves = [
permanent(target) ? null : `move ${name(target)} to ${early}`,
permanent(doc.path) ? null : `move ${name(doc.path)} to ${late}`,
].filter((move) => move !== null);
const fix =
moves.length > 0 ? `${moves.join(" or ")} with \`quest mv\`, or remove the link` : "remove the link";
found.push({
path: doc.path,
line: entry.line,
message: `in milestone ${early}, requires ${target} in later milestone ${late}; ${fix}`,
});
}
}
}

/**
* `Required` must be acyclic. A cycle is a set of quests none of which can ever
* start, and walking the links to rule one out is exactly the manual step an
Expand Down
79 changes: 74 additions & 5 deletions test/tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,12 +405,18 @@ describe("finished milestones", () => {
expect(t.ready()).toEqual([]);
});

// moq-dev/moq's m1 lists m0 quests: those blockers keep it open.
// A milestone README may list earlier quests: those blockers keep it open.
test("a milestone with only blockers left is open", () => {
gated()
.append("quest/m0/README.md", "\n## Required\n\n- [Other](/quest/m1/other.md)\n")
.remove("quest/m0/epic")
.write("quest/m1/other.md", "# [S] Other\n\n## Goal\n\nOpen work m0 waits on.\n")
tree()
.write(
"quest/m1/README.md",
"# M1\n\n## Goal\n\nThe next milestone.\n\n## Required\n\n- [One](/quest/m0/epic/one.md)\n",
)
.write("quest/m2/README.md", "# M2\n\n## Goal\n\nLater.\n")
.write(
"quest/m2/gate.md",
"# [S] Gate\n\n## Goal\n\nRuns once m1 finishes.\n\n## Required\n\n- [M1](/quest/m1/README.md)\n",
)
.accepts();
});

Expand Down Expand Up @@ -815,3 +821,66 @@ describe("scheduled", () => {
expect(t.ready()).toEqual(["quest/m0/epic/one.md"]);
});
});

// `quest unblocked` ranks a blocker by its own milestone, so a link to a later
// one stalls the earlier quest behind all of that milestone's work.
describe("Required links to a later milestone", () => {
const later = () =>
tree()
.write("quest/m1/README.md", "# M1\n\n## Goal\n\nLater.\n")
.write("quest/m1/late.md", "# [S] Late\n\n## Goal\n\nLater work.\n");
const REQ = "\n## Required\n\n- [Late](/quest/m1/late.md)\n";

test("a quest requiring a later quest", () => {
expect(later().append("quest/m0/epic/two.md", REQ).findings()).toEqual([
"quest/m0/epic/two.md:13: in milestone m0, requires quest/m1/late.md in later milestone m1; move quest/m1/late.md to m0 or move quest/m0/epic/two.md to m1 with `quest mv`, or remove the link",
]);
});

test("an epic README requiring a later quest", () => {
const findings = later().append("quest/m0/epic/README.md", REQ).findings();
expect(findings).toHaveLength(1);
expect(findings[0]).toContain("move quest/m1/late.md to m0 or move quest/m0/epic to m1");
});

test("a milestone README offers only moving the other side", () => {
const findings = later().append("quest/m0/README.md", REQ).findings();
expect(findings).toHaveLength(1);
expect(findings[0]).toContain(
"in later milestone m1; move quest/m1/late.md to m0 with `quest mv`, or remove the link",
);
});

test("a later milestone README offers only moving the requirer", () => {
const findings = later()
.append("quest/m0/epic/two.md", "\n## Required\n\n- [M1](/quest/m1/README.md)\n")
.findings();
expect(findings[0]).toContain("move quest/m0/epic/two.md to m1 with `quest mv`, or remove the link");
});

test("m10 follows m9", () => {
const t = tree()
.write("quest/m9/README.md", "# M9\n\n## Goal\n\nNine.\n")
.write("quest/m9/nine.md", "# [S] Nine\n\n## Goal\n\nNine.\n")
.write("quest/m10/README.md", "# M10\n\n## Goal\n\nTen.\n")
.write("quest/m10/a.md", "# [S] A\n\n## Goal\n\nA.\n\n## Required\n\n- [M9](/quest/m9/README.md)\n");
t.accepts();
t.write("quest/m10/a.md", "# [S] A\n\n## Goal\n\nA.\n").write(
"quest/m9/b.md",
"# [S] B\n\n## Goal\n\nB.\n\n## Required\n\n- [A](/quest/m10/a.md)\n",
);
expect(t.findings()).toEqual([
"quest/m9/b.md:9: in milestone m9, requires quest/m10/a.md in later milestone m10; move quest/m10/a.md to m9 or move quest/m9/b.md to m10 with `quest mv`, or remove the link",
]);
});

test("earlier and same-milestone links pass", () => {
later()
.write("quest/m1/other.md", "# [S] Other\n\n## Goal\n\nSame milestone.\n")
.append(
"quest/m1/late.md",
"\n## Required\n\n- [One](/quest/m0/epic/one.md)\n- [Other](/quest/m1/other.md)\n",
)
.accepts();
});
});
Loading