diff --git a/README.md b/README.md index c8be04c2..4f1d8f7a 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,7 @@ quest rm -r quest/m1/drain under a moved epic. `rm` drops each `Required`, `Related`, or `Closes` entry that links to what it deleted, and the heading if that was the last entry. It also drops every `Required` link to a finished milestone, including one it -just finished, but keeps the root's listing of the milestone. +just finished, and every epic's `Required` link to a quest in its own folder. Both stage the change, print one line per file (`R from -> to`, `D path`, or `M path`), and report on stderr any other mention of the path, such as prose or files outside `quest/`, without editing it. They run `quest check` on the @@ -333,12 +333,15 @@ A clean merge can still leave this branch's list links to quests the base finished, which `quest check` then flags. `quest check --fix` drops every `Required`, `Related`, or `Closes` entry linking to a quest deleted in the history of `HEAD`, and of `MERGE_HEAD` during a merge, that the working tree -lacks, and prints `M path` for each edited document. It then promotes a section -heading written below `##` to `##` where nothing but its own content follows it -before the next `##` heading, and no `##` section of that name exists. It leaves -the edits unstaged and refuses while a quest file has unresolved conflicts. A -quest moved rather than deleted, a link that never named a quest, a link in -prose, and any other misleveled heading stay for `quest check` to report. +lacks, and every epic's `Required` entry naming a quest in its own folder, +which the epic already waits for, and prints `M path` for each edited +document. That part also migrates a tree from before epics stopped listing +their children. It then promotes a section heading written below `##` to `##` +where nothing but its own content follows it before the next `##` heading, and +no `##` section of that name exists. It leaves the edits unstaged and refuses +while a quest file has unresolved conflicts. A quest moved rather than deleted, +a link that never named a quest, a link in prose, and any other misleveled +heading stay for `quest check` to report. ## Wait for pull request activity diff --git a/quest/README.md b/quest/README.md index 05a03a75..228412cf 100644 --- a/quest/README.md +++ b/quest/README.md @@ -10,9 +10,3 @@ versioned Markdown, with a standalone CLI and a reversible adoption path. `m0` is adoption on moq-dev's own repositories, `m1` the public release, and `m2` later work. Add later horizons when concrete work warrants them, keeping milestone numbers stable as earlier milestones complete. - -## Required - -- [M0: moq-dev adoption](/quest/m0/README.md) - kix at quest.moq.dev, used daily on moq-dev/moq, moq-dev/quest, and moq-dev/moq.pro, beside the standalone CLI -- [M1: Public release](/quest/m1/README.md) - kix.quest open to any maintainer, with onboarding, contributor sandboxing, the themed site, and launch material -- [M2: Later work](/quest/m2/README.md) - cloud runners, a native mobile app, shared runners, and donated reviews and CI diff --git a/quest/m0/README.md b/quest/m0/README.md index 4f549088..c6f66c69 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -91,17 +91,3 @@ ownership-manifest design: limits). Prefer offline, deterministic commands, and keep GitHub calls in the CLI rather than skill prose. - Repository-specific rules stay in the repository's own root instructions. - -## Required - -- [Land through a serial merge queue with an async fixer](/quest/m0/queue-watcher/README.md) - GitHub's merge queue, or the watcher's own where GitHub has none, lands one PR at a time, and a host watcher fixes and re-enqueues each PR it drops -- [Kix decision UI](/quest/m0/kix/README.md) - planning and implementation through prompts and summaries, run on the maintainer's own runners -- [Route quests to models by size](/quest/m0/model-routing/README.md) - `/quest-spawn` starts each quest on the first model of its size's ranked list that can run, reviews rank the same way, and PRs record size, implementer, and reviewer -- [Repositories and hosts follow quest's main](/quest/m0/pin-sync.md) - every push to main reaches each enrolled repository and host through a bot-queued pin bump -- [Cut agent token waste](/quest/m0/token-waste/README.md) - sessions stop paying for loading rules, a second review pass, polling, oversized coordinators, and merge churn -- [Model check](/quest/m0/model-check.md) - recurring: the first audit of the routed PRs retunes the built-in size lists -- [Codex cloud off](/quest/m0/codex-cloud-off.md) - automatic Codex cloud reviews stop once no open PR relies on them -- [Codex project-config skip](/quest/m0/codex-project-config.md) - once Codex keeps a supported way to skip a project's `.codex/` layer, reviews keep the user's config -- [Agents get the dev shell from user settings](/quest/m0/user-dev-shell/README.md) - maintainers' own Claude and Codex settings load the dev shell; repositories drop dev-shell hooks, Quest worktree hooks, and `nix develop` instructions, and `quest init` writes no hooks -- [claude rm upstream report](/quest/m0/claude-rm-upstream.md) - posting approved: ask Claude Code to run `WorktreeRemove` instead of advising an unlock -- [Three generic rules in the shared context](/quest/m0/context-rules.md) - `quest context` adds matching conventions, never loosening a flaky test, and short names under a namespace diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md index 9e22d582..ba76f415 100644 --- a/quest/m0/kix/README.md +++ b/quest/m0/kix/README.md @@ -136,10 +136,3 @@ fixes what it finds: [moq-dev/quest#209](https://github.com/moq-dev/quest/pull/209) assumes is the comparison GitHub makes. Try it with a real PR that edits a workflow (decided 2026-10-24, user). - -## Required - -- [Operator setup](/quest/m0/kix/operator-setup.md) - pilot installations and a verified test PR on moq-dev/moq and moq-dev/quest -- [Private repositories](/quest/m0/kix/private.md) - moq-dev/moq.pro enrolls, visible only to signed-in users who can read it -- [Runner account pool](/quest/m0/kix/cliproxy.md) - the runner routes every Claude and Codex job and review through its own CLIProxyAPI pool -- [Web execution](/quest/m0/kix/execution.md) - spawn and iterate through the UI, ending in contribution PRs diff --git a/quest/m0/queue-watcher/README.md b/quest/m0/queue-watcher/README.md index 8c3ea64f..11201104 100644 --- a/quest/m0/queue-watcher/README.md +++ b/quest/m0/queue-watcher/README.md @@ -144,11 +144,3 @@ Shared shape, for the children to follow: class (`QUEST_GATE_PRIORITY=1`, which waits for memory but never for load) lets them admit ahead of agent load, set from moq's workflow or the runner's environment. - -## Required - -- [The other repositories follow](/quest/m0/queue-watcher/follow.md) - quest, rind and rust2 are cut over and landing; waits for a fork PR to land through one of their queues -- [Harden the queue runners](/quest/m0/queue-watcher/runners.md) - each queue runner runs only its repository's queue workflow, and every outside fork PR needs approval -- [moq.pro lands main through the own queue](/quest/m0/queue-watcher/pro-cutover.md) - moq.pro's `main` leaves the batching gate for the watcher's own queue -- [Retire the batching gate](/quest/m0/queue-watcher/retire.md) - once no repository lands through `quest land serve`, its batching code and host units go -- [kix.quest shows each repository's landing queue](/quest/m0/queue-watcher/queue-page.md) - one page per repository with its queue order, current build, and recent drops, in both modes diff --git a/quest/m0/token-waste/README.md b/quest/m0/token-waste/README.md index a081e38a..ea5fa14b 100644 --- a/quest/m0/token-waste/README.md +++ b/quest/m0/token-waste/README.md @@ -66,13 +66,6 @@ Decision prompts, chosen options marked ✅: semver-breaking features) is moq-dev/moq's own plan: ✅ re-plan moq separately (rejected: make it quest's convention; not now). -## Required - -- [Epics list no children](/quest/m0/token-waste/child-lists.md) - an epic's children are its directory, so finishing a quest edits no README -- [Mechanical rules become checks](/quest/m0/token-waste/rule-checks.md) - hooks enforce em dashes, push refspecs, no rebase or force-push, the footer, and `.tmp/`, and their prose leaves the always-loaded rules -- [quest usage](/quest/m0/token-waste/usage.md) - per quest and PR: which models and efforts worked on it, their tokens and API-equal cost, and how the work went; the epic's re-measure runs it -- [Point to token-max](/quest/m0/token-waste/token-max.md) - `SETUP.md` recommends the standalone settings doctor, and the re-measure runs it - ## Related - [Model check](/quest/m0/model-check.md) - audits the size tables the review quest changes diff --git a/quest/m0/token-waste/child-lists.md b/quest/m0/token-waste/child-lists.md deleted file mode 100644 index 8ca4a561..00000000 --- a/quest/m0/token-waste/child-lists.md +++ /dev/null @@ -1,41 +0,0 @@ -# [S] Epics list no children - -## Goal - -This repository's epic and milestone READMEs no longer list their children, -and `quest check` rejects a README `Required` entry naming a quest in its own -directory. Finishing a quest then deletes its file and edits no README, so -PRs that finish sibling quests stop conflicting there. - -## Plan - -Decided 2026-11-05 (user, /quest-plan, the -[token waste](/quest/m0/token-waste/README.md) audit): an epic's children are -the quests in its directory, its `Required` lists only what blocks it, and -path order replaces list order. - -Measured, moq-dev PRs created 2026-10-06 to 11-05: - -- `quest/m1/README.md` was touched by 33% of merged moq-dev/moq PRs, and - milestone READMEs as a group by 45%; in moq-dev/rust2, `quest/m3/README.md` - by 290 of 585. -- GitHub's merge queue cannot run quest's merge driver and ejected 723 PRs - for conflicts. - -Decided 2026-11-07 (user): ✅ stage it in two PRs (rejected: admin-bypass -merge once; skip; delete). The merge queue checks a PR with the CLI built -from its base, which rejected the migrated tree. -[moq-dev/quest#518](https://github.com/moq-dev/quest/pull/518) shipped -directory membership with a validator that accepts both formats: an epic's -`Required` link to a quest it holds is allowed and counts once, as a child. -This quest is the second PR: - -- Make `remove()` (`src/core/edit.ts`) drop every epic's `Required` link to a - quest it holds, so `quest check --fix`, `quest rm`, and the land daemon's - base-merge prune strip old child lists, and let `quest check --fix` run with - no history to read. #518's commit caa32fc3 has both, with tests. -- Run `quest check --fix` on this tree and on prose that describes the lists. -- Turn `requires X, which this epic holds` into a `quest check` error. -- The PR is breaking: other repositories migrate with `quest check --fix` on - their pin bump past it, as - the moq-dev pin bumps did. diff --git a/quest/m0/user-dev-shell/README.md b/quest/m0/user-dev-shell/README.md index 809b812f..360d8d0e 100644 --- a/quest/m0/user-dev-shell/README.md +++ b/quest/m0/user-dev-shell/README.md @@ -70,7 +70,3 @@ The user-level hooks live in [kixelated/dotfiles](https://github.com/kixelated/d installed into each Claude profile's `settings.json` and Codex's user config, and generalize this repository's `.claude/hooks/direnv.sh` and moq-dev/moq's `.codex/hooks/direnv.sh`. - -## Required - -- [moq.pro drops its Codex dev-shell config](/quest/m0/user-dev-shell/repo-config.md) - once a fresh Codex session there loads the dev shell without the repo hooks diff --git a/quest/m1/README.md b/quest/m1/README.md index 44f33f48..3a837559 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -19,16 +19,3 @@ earlier "Later work" milestone became m2. - ✅ Move to kix.quest in m1 - Stay on quest.moq.dev - Decide later - -## Required - -- [User state in their RunnerHub](/quest/m1/user-state.md) - each user's runners, jobs, inbox, and push subscriptions live in their Durable Object, and D1 keeps repository data and other cross-user state -- [Open kix.quest](/quest/m1/open.md) - the Worker moves to kix.quest and any owner may enroll -- [Release with worktree hooks](/quest/m1/release.md) - tag a release that has `quest worktree hook`, so SETUP.md's release routes work -- [Kix for any repository](/quest/m1/kix/README.md) - onboarding, read-only CI checks, contributor sandboxing, and the maintainer guide -- [Quest log theme](/quest/m1/theme/README.md) - an MMO-style identity, copy, board, landing page, and CLI output for the demo and launch -- [Launch material](/quest/m1/launch.md) - README, quickstart, demo, comparison, and launch-post drafts -- [Gate CPU weight](/quest/m1/gate-cpu-weight.md) - gated commands run at a lower CPU weight than the interactive session, so concurrent gates no longer starve the desktop -- [Retire Decisions needed](/quest/m1/decisions-needed.md) - only `## Waiting` hands a draft back, once no open draft uses the old heading -- [Bun EFAULT report](/quest/m1/bun-efault.md) - report concurrent `rmSync` `EFAULT` on Windows, and drop the workaround once Bun fixes it -- [Model check](/quest/m1/model-check.md) - recurring each milestone: the built-in implementation and review tables use the newest models at efforts retuned from outcomes, and the live sandbox test passes diff --git a/quest/m1/kix/README.md b/quest/m1/kix/README.md index 9a3ca15e..65b84813 100644 --- a/quest/m1/kix/README.md +++ b/quest/m1/kix/README.md @@ -12,8 +12,3 @@ unconfined on their own machines. Moved from m0 on 2026-10-30 (user, /quest-plan): moq-dev's own repositories already carry Quest, are enrolled by the operator, and are worked on by their maintainers, so they need none of this first. - -## Required - -- [Contributor sandboxing](/quest/m1/kix/sandbox/README.md) - contributors' PR heads build in a VM, on stock Ubuntu and verified on macOS -- [Onboarding guide](/quest/m1/kix/guide.md) - a complete maintainer path, from runner setup through a reviewed PR diff --git a/quest/m1/kix/sandbox/README.md b/quest/m1/kix/sandbox/README.md index 164baf8b..5a5caa27 100644 --- a/quest/m1/kix/sandbox/README.md +++ b/quest/m1/kix/sandbox/README.md @@ -92,8 +92,3 @@ Shipped in [moq-dev/quest#212](https://github.com/moq-dev/quest/pull/212) - Sources: [Claude Code sandboxing](https://code.claude.com/docs/en/sandboxing) and [sandbox-runtime](https://www.npmjs.com/package/@anthropic-ai/sandbox-runtime). - -## Required - -- [macOS sandbox](/quest/m1/kix/sandbox/macos.md) - the boundary verified under Seatbelt, then the answer-approval gate removed -- [Untrusted devShells](/quest/m1/kix/sandbox/vm.md) - untrusted PR heads build their devShell in a per-job VM diff --git a/quest/m1/theme/README.md b/quest/m1/theme/README.md index da40cc8a..dc04f193 100644 --- a/quest/m1/theme/README.md +++ b/quest/m1/theme/README.md @@ -110,10 +110,6 @@ Decided while planning on 2026-10-05, amended 2026-10-14 where noted: uses these terms. A blocked waypoint's tooltip lists what it needs under "Required:". -## Required - -- [Landing page](/quest/m1/theme/landing.md) - the home page's calls to action lead to sign-in, onboarding, and the setup paste - ## Related - [Launch material](/quest/m1/launch.md) - posts, quickstart, demo, and comparison written in the same voice diff --git a/quest/m2/README.md b/quest/m2/README.md index 7e3130b7..7c9eca1c 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -5,15 +5,3 @@ Extend kix.quest with cloud runners, a native mobile app, runners shared with other projects, donated reviews and CI, stronger isolation, and additional login providers. This milestone does not gate the public release. - -## Required - -- [Cloud runners](/quest/m2/cloud/README.md) - one-click free cloud runners, API-key and donor funding, then paid compute -- [Native mobile app](/quest/m2/mobile-app.md) - push notifications with quick replies to decisions -- [Shared runners](/quest/m2/donor-runner.md) - offer your runner and accounts to other projects -- [Review and check automation](/quest/m2/automation/README.md) - donated reviews, CI, and a shared Nix cache -- [opencode reviewers](/quest/m2/review-harnesses.md) - `quest review` runs opencode read-only, for the council's DeepSeek member -- [Review council](/quest/m2/council.md) - `/quest-council` reviews a PR with one blind model per maker and merges their findings with agreement counts -- [Donor isolation](/quest/m2/donor-isolation.md) - stronger boundaries for untrusted shared jobs -- [ChatGPT hosted runs](/quest/m2/chatgpt-hosted.md) - conditional on provider approval and current terms -- [ChatGPT approval](/quest/m2/chatgpt-waitlist.md) - resolve hosted-use approval before implementation diff --git a/quest/m2/automation/README.md b/quest/m2/automation/README.md index 1c33ccb7..dd6ed835 100644 --- a/quest/m2/automation/README.md +++ b/quest/m2/automation/README.md @@ -10,9 +10,3 @@ authorized agent reviews and CI capacity using ordinary GitHub interfaces. Decided 2026-10-13: C1 first, then C2. Existing repository checks remain part of implementation; this epic adds independent donated review and CI jobs. No custom review database or git-note approval protocol is introduced. - -## Required - -- [Agent reviews](/quest/m2/automation/review.md) - attributed, advisory GitHub reviews -- [Donated checks](/quest/m2/automation/checks.md) - repository-defined check execution with explicit trust -- [Nix cache](/quest/m2/automation/cache.md) - optional main-only signed build cache diff --git a/quest/m2/cloud/README.md b/quest/m2/cloud/README.md index cf1fabc2..6c50de33 100644 --- a/quest/m2/cloud/README.md +++ b/quest/m2/cloud/README.md @@ -26,10 +26,3 @@ Decided 2026-10-15 in the decisions-not-implementations interview: for the cloud runners that depend on it. - Funding setup and operating guidance stay in existing service documentation; each implementation updates the docs it changes. - -## Required - -- [API funding](/quest/m2/cloud/funding.md) - maintainer API keys, the credential proxy, and durable usage accounting -- [Hosted runners](/quest/m2/cloud/runners.md) - free, capped cloud runners enabled with one click -- [Donor allocations](/quest/m2/cloud/donors.md) - shared API budgets after maintainer-funded cloud runners work -- [Paid compute](/quest/m2/cloud/billing.md) - charge for cloud runner compute once free capacity is not enough diff --git a/src/cli/main.ts b/src/cli/main.ts index 080d84cc..f2fbc568 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -170,7 +170,8 @@ export function main(argv: string[], out: Output): number | Promise { "first: a stale base can predate the quest and skip it.\n\n" + "With --fix, first drop every Required, Related, or Closes entry that links to a quest deleted in " + "the history of HEAD, and of MERGE_HEAD during a merge, that the working tree lacks, as quest rm " + - "does, such as the quests a merged base finished, with any heading left empty. It then promotes a " + + "does, such as the quests a merged base finished, and every epic's Required entry naming a quest in " + + "its own directory, which the epic already waits for, with any heading left empty. It then promotes a " + "section heading written below ## to ## where nothing but its own content follows it before the next " + "## heading, and no ## section of that name exists. It prints M for each edited document and leaves " + "the edits unstaged. A quest moved rather than deleted, a link that never " + @@ -179,13 +180,18 @@ export function main(argv: string[], out: Output): number | Promise { ) .option("--base ", "Also check the quest branch's diff from its merge base with this ref") .option("--branch ", "The PR's head branch, when it differs from the current branch (needs --base)") - .option("--fix", "First drop list links to quests the history deleted, and promote misleveled section headings") + .option( + "--fix", + "First drop list links to quests the history deleted and epics' links to their own quests, and promote misleveled section headings", + ) .action((options: { base?: string; branch?: string; fix?: boolean }) => { if (options.branch !== undefined && options.base === undefined) { throw new UsageError("--branch requires --base"); } if (options.fix) { - const edited = new Set([...fixLinks(root()), ...fixHeadings(root())]); + // fixLinks checks the repair preconditions before anything is written; it runs again + // after promotion, since a promoted `Required` can hold links it then drops. + const edited = new Set([...fixLinks(root()), ...fixHeadings(root()), ...fixLinks(root())]); for (const line of [...edited].sort()) print(line); } const tree = checkout(root(), warn); @@ -1315,7 +1321,7 @@ export function main(argv: string[], out: Output): number | Promise { "Delete a quest, or an epic and the quests under it, with git rm, and drop every Required, Related, " + "or Closes entry that links to it, with any heading left empty. It also drops every Required link to a " + "finished milestone, one whose directory holds no other quest and whose README has no Required left, " + - "except the root's own listing. The change is staged.\n\n" + + "and every epic's Required entry naming a quest in its own directory. The change is staged.\n\n" + "Refuses outside a git work tree. An epic is its directory or README; removing one that holds other " + "quests requires -r. The root epic and milestones are permanent.\n\n" + "Prints D for each deleted quest and M for each edited document. Mentions that are not list entries, " + diff --git a/src/cli/move.ts b/src/cli/move.ts index 525fe97f..f5b7abe2 100644 --- a/src/cli/move.ts +++ b/src/cli/move.ts @@ -74,12 +74,15 @@ export async function moveQuest( mkdirSync(join(root, posix.dirname(to)), { recursive: true }); git(root, ["mv", "--", from, to]); write(root, edited.texts); + // The move can put a required quest inside the epic requiring it, which then holds it. + const holding = remove(sources(root), () => false); + write(root, holding); const warnings = edited.missed.map( (link) => `${relocate(link.path, from, to) ?? link.path}:${link.line}: links ${link.target} in a form mv cannot rewrite; update it by hand`, ); return { - changes: [`R ${from} -> ${to}`, ...modified(edited.texts)], + changes: [`R ${from} -> ${to}`, ...modified(new Map([...edited.texts, ...holding]))], warnings: [...warnings, ...mentions(root, from)], findings: findings(root), }; @@ -87,7 +90,8 @@ export async function moveQuest( /** * Delete a quest, or an epic with the quests under it, and drop every list - * entry that links to what was deleted. The root and milestones are permanent. + * entry that links to what was deleted, as `remove` does. The root and + * milestones are permanent. */ export function removeQuest(root: string, arg: string, recursive: boolean): Report { workTree(root); @@ -124,10 +128,11 @@ export function removeQuest(root: string, arg: string, recursive: boolean): Repo /** * Drop every list entry that links to a quest deleted in the history of `HEAD`, and of * `MERGE_HEAD` during a merge, that the working tree lacks, as `quest rm` drops the links to a - * quest it deletes. Merging the base brings in the quests it finished as deletions, which leaves - * this branch's links to them dangling. A quest the history moved is a rename, not a deletion, and - * a link to a path that never held a quest is a mistake; both stay for `quest check` to report, as - * does a link in prose. Writes the edited documents without staging them, and refuses while a + * quest it deletes, and every epic's `Required` entry naming a quest in its own directory, which + * the epic already waits for. Merging the base brings in the quests it finished as deletions, which + * leaves this branch's links to them dangling. A quest the history moved is a rename, not a deletion, + * and a link to a path that never held a quest is a mistake; both stay for `quest check` to report, + * as does a link in prose. Writes the edited documents without staging them, and refuses while a * quest file is unmerged, since its conflict markers are not yet a document. Returns `M path` for * each. */ @@ -141,23 +146,24 @@ export function fixLinks(root: string): string[] { throw new Error(`resolve the merge conflicts in ${[...new Set(unmerged)].join(", ")} before quest check --fix`); } const heads = ["HEAD", "MERGE_HEAD"].filter((ref) => verified(root, ref)); - if (heads.length === 0) return []; const deleted = new Set( - // `--relative` names paths from `root`, as the tree's links do, when the tree is nested in its repository. - git(root, [ - "log", - "--relative", - "--diff-filter=D", - "--find-renames", - "--name-only", - "--format=", - "-z", - ...heads, - "--", - "quest", - ]) - .split("\0") - .filter(isQuest), + heads.length === 0 + ? [] + : // `--relative` names paths from `root`, as the tree's links do, when the tree is nested in its repository. + git(root, [ + "log", + "--relative", + "--diff-filter=D", + "--find-renames", + "--name-only", + "--format=", + "-z", + ...heads, + "--", + "quest", + ]) + .split("\0") + .filter(isQuest), ); const edited = remove(sources(root), (path) => deleted.has(path) && !exists(root, path)); for (const [path, text] of edited) writeFileSync(join(root, path), text); diff --git a/src/core/doc.ts b/src/core/doc.ts index d391185e..9e528604 100644 --- a/src/core/doc.ts +++ b/src/core/doc.ts @@ -212,17 +212,12 @@ export function isEpic(docs: readonly Doc[], doc: Doc): boolean { return permanent(doc.path) || docs.some((d) => holds(doc.path, d.path)); } -/** - * What must finish before `doc`: its `Required` quests, then, for an epic, its - * children. An epic's `Required` link to one of its children, left from a tree - * that listed them, counts once. - */ +/** What must finish before `doc`: its `Required` quests, then, for an epic, its children. */ export function requires(docs: readonly Doc[], doc: Doc): string[] { - const kids = children(docs, doc); const required = entries(doc, "Required") .map((entry) => (entry.target === null ? null : rooted(entry.target))) - .filter((target): target is string => target !== null && !kids.includes(target)); - return [...required, ...kids]; + .filter((target): target is string => target !== null); + return [...required, ...children(docs, doc)]; } /** diff --git a/src/core/edit.ts b/src/core/edit.ts index 7228b068..2186a153 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -5,7 +5,7 @@ // dependency graph. Anything else that mentions a path is prose, which the // caller reports rather than rewriting. -import { type Doc, type Entry, type Heading, has, parse, rooted, withoutFragment } from "./doc"; +import { type Doc, type Entry, type Heading, has, holds, parse, rooted, withoutFragment } from "./doc"; import { HEADINGS, LIST_SECTIONS, finishedMilestone } from "./rules"; /** One quest document as loaded: its repository-relative path and source text. */ @@ -106,24 +106,35 @@ function following(doc: Doc, heading: Heading): Heading[] { * Drop every list entry, in a list section, that opens with a link to a * deleted document, and a section heading left with no entries. Then drop * every `Required` link to a finished milestone, whether this removal emptied - * it or the link was already stale, repeating until nothing changes so the - * result passes `quest check`. Returns the new text of every surviving + * it or the link was already stale, and every epic's `Required` link to a + * quest it holds, which it already waits for, repeating until nothing changes + * so the result passes `quest check`. Returns the new text of every surviving * document that changed. */ export function remove(sources: Source[], deleted: (path: string) => boolean): Map { const texts = new Map(sources.filter((s) => !deleted(s.path)).map((s) => [s.path, s.text])); const changed = new Set(); + const target = (entry: Entry) => (entry.target === null ? null : rooted(entry.target)); const linksDeleted = (entry: Entry) => { - if (entry.section === null || !LIST_SECTIONS.includes(entry.section) || entry.target === null) return false; - const target = rooted(entry.target); - return target !== null && deleted(target); + if (entry.section === null || !LIST_SECTIONS.includes(entry.section)) return false; + const path = target(entry); + return path !== null && deleted(path); + }; + // Only a quest that exists is held: a mistyped link stays for `quest check` to report. + const held = (doc: Doc, entry: Entry) => { + const path = target(entry); + return entry.section === "Required" && path !== null && texts.has(path) && holds(doc.path, path); }; for (let progress = true; progress;) { progress = false; const docs = [...texts].map(([path, text]) => parse(path, text)); const finished = finishedMilestone(docs); for (const doc of docs) { - const text = drop(texts.get(doc.path)!, doc, (entry) => linksDeleted(entry) || finished(doc, entry)); + const text = drop( + texts.get(doc.path)!, + doc, + (entry) => linksDeleted(entry) || finished(doc, entry) || held(doc, entry), + ); if (text === null) continue; texts.set(doc.path, text); changed.add(doc.path); diff --git a/src/core/index.ts b/src/core/index.ts index 1604377f..5cdaa5e9 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -16,6 +16,7 @@ export { children, entries, has, + holds, isQuest, isEpic, owner, diff --git a/src/core/ready.ts b/src/core/ready.ts index 8d8e4d8c..e9d9d3f2 100644 --- a/src/core/ready.ts +++ b/src/core/ready.ts @@ -84,7 +84,6 @@ function expand(docs: Doc[], byPath: Map, doc: Doc, stack: string[] // everything that greps for it, including `quest check`, which reports it. // Calling it ready here would make this the one tool that disagrees. const required = entries(doc, "Required"); - const kids = children(docs, doc); if (has(doc, "Required") && required.length === 0) { found.push({ path: null, @@ -96,11 +95,13 @@ function expand(docs: Doc[], byPath: Map, doc: Doc, stack: string[] // A bullet that does not open with a link into the tree is invalid, and // nothing here can clear it, so it is a blocker with nothing under it. const target = entry.target === null ? null : rooted(entry.target); - // An old child list's entry is listed below, with the children. - if (target !== null && kids.includes(target)) continue; found.push(blocker(docs, byPath, target !== null && byPath.has(target) ? target : null, entry.text, stack)); } - for (const child of kids) found.push(blocker(docs, byPath, child, child, stack)); + // An unmigrated epic may still list a child under `Required`; name it once. + const listed = new Set(found.map((b) => b.path)); + for (const child of children(docs, doc)) { + if (!listed.has(child)) found.push(blocker(docs, byPath, child, child, stack)); + } return found; } diff --git a/src/core/rules.ts b/src/core/rules.ts index 6ffdc8de..60d8cc1b 100644 --- a/src/core/rules.ts +++ b/src/core/rules.ts @@ -222,8 +222,8 @@ function links(found: Finding[], exists: (path: string) => boolean, known: Set, docs: Doc[]) { found.push({ path: doc.path, line: entry.line, message: `requires ${entry.target} twice` }); } seen.add(target); + if (holds(doc.path, target)) { + found.push({ + path: doc.path, + line: entry.line, + message: `requires ${entry.target}, which this epic holds; an epic waits for everything in its directory, so remove the entry (quest check --fix does)`, + }); + } } } } @@ -268,8 +275,8 @@ function membership(found: Finding[], known: Set, docs: Doc[]) { * other quest and its README has no `## Required`. * * Readiness stays a grep for the heading; this rule is what keeps that answer - * true once the milestone empties. The root's own listing of its milestones is - * membership, not a blocker, so it is exempt. + * true once the milestone empties. A root entry naming a milestone is the + * membership rule's finding, not this one's. */ function milestones(found: Finding[], docs: Doc[]) { const finished = finishedMilestone(docs); diff --git a/src/core/unblocked.ts b/src/core/unblocked.ts index a45f18cd..5a5fd606 100644 --- a/src/core/unblocked.ts +++ b/src/core/unblocked.ts @@ -37,13 +37,8 @@ export function ranking(docs: Doc[]): Ranking { reverse.set(target, dependents); }; for (const doc of docs) { - const kids = children(docs, doc); - for (const entry of entries(doc, "Required")) { - const target = rooted(entry.target!)!; - // An old child list's entry is membership, which the children below record. - if (!kids.includes(target)) depend(target, doc.path, false); - } - for (const child of kids) depend(child, doc.path, true); + for (const entry of entries(doc, "Required")) depend(rooted(entry.target!)!, doc.path, false); + for (const child of children(docs, doc)) depend(child, doc.path, true); } // Assign the root's subtrees their priority before scoring. In particular, diff --git a/test/move.test.ts b/test/move.test.ts index 75be6873..e1c3423e 100644 --- a/test/move.test.ts +++ b/test/move.test.ts @@ -10,7 +10,7 @@ import { move, remove } from "../src/core"; import { openPullRequests } from "../src/cli/claims"; import { GitHub, type GitHubDependencies } from "../src/cli/github"; import { main } from "../src/cli/main"; -import { EPIC_README, TWO, Tree, initRepository, tempDir } from "./fixture"; +import { EPIC_README, M0_README, ROOT_README, TWO, Tree, initRepository, tempDir } from "./fixture"; // Each test spawns git up to a dozen times, which a busy Windows runner can stretch past 5 s. vi.setConfig({ testTimeout: 30_000 }); @@ -120,11 +120,9 @@ describe("remove", () => { "# [S] A\n\n## Goal\n\nAfter [B](/quest/b.md).\n\n- [B](/quest/b.md) in a goal list\n\n## Related\n\nContext.\n", ); }); - test("drops links to finished milestones until nothing changes, keeping the root's listing", () => { - const root = - "# Quests\n\n## Goal\n\nG.\n\n## Required\n\n- [M0](/quest/m0/README.md)\n- [M1](/quest/m1/README.md)\n"; + test("drops links to finished milestones until nothing changes", () => { const sources = [ - { path: "quest/README.md", text: root }, + { path: "quest/README.md", text: "# Quests\n\n## Goal\n\nG.\n" }, // m0 finishes once b goes, which finishes m1, which only waited on m0. { path: "quest/m0/README.md", text: "# M0\n\n## Goal\n\nG.\n\n## Required\n\n- [B](/quest/b.md)\n" }, { @@ -147,6 +145,32 @@ describe("remove", () => { expect(texts.get("quest/m1/README.md")).toBe("# M1\n\n## Goal\n\nG.\n"); expect(texts.get("quest/a.md")).toBe("# [S] A\n\n## Goal\n\nG.\n\n## Related\n\n- [M0](/quest/m0/README.md)\n"); }); + + // An epic waits for everything in its directory, so its links to those quests + // are the child lists this format retired. A sibling's link is a real blocker, + // and a link to no quest is a mistake for the check to report. + test("drops an epic's Required links to quests it holds, at any depth", () => { + const readme = + "# Epic\n\n## Goal\n\nG.\n\n## Required\n\n- [A](/quest/m0/epic/a.md)\n" + + "- [Outside](/quest/m0/outside.md)\n- [Deep](/quest/m0/epic/sub/deep.md)\n- [Typo](/quest/m0/epic/typo.md)\n\n" + + "## Related\n\n- [A](/quest/m0/epic/a.md)\n"; + const sibling = "# [S] B\n\n## Goal\n\nG.\n\n## Required\n\n- [A](/quest/m0/epic/a.md)\n"; + const quest = (title: string) => `# [S] ${title}\n\n## Goal\n\nG.\n`; + const texts = remove( + [ + { path: "quest/m0/epic/README.md", text: readme }, + { path: "quest/m0/epic/a.md", text: quest("A") }, + { path: "quest/m0/epic/b.md", text: sibling }, + { path: "quest/m0/epic/sub/deep.md", text: quest("Deep") }, + ], + () => false, + ); + expect([...texts.keys()]).toEqual(["quest/m0/epic/README.md"]); + expect(texts.get("quest/m0/epic/README.md")).toBe( + "# Epic\n\n## Goal\n\nG.\n\n## Required\n\n- [Outside](/quest/m0/outside.md)\n- [Typo](/quest/m0/epic/typo.md)\n\n" + + "## Related\n\n- [A](/quest/m0/epic/a.md)\n", + ); + }); }); describe("quest mv", () => { @@ -163,6 +187,20 @@ describe("quest mv", () => { tree.accepts(); }); + test("drops a Required link the move puts inside the requiring epic", async () => { + const tree = committed( + Tree.baseline() + .write("quest/m0/a.md", "# [S] A\n\n## Goal\n\nG.\n") + .write("quest/m0/epic/README.md", `${EPIC_README.trimEnd()}\n\n## Required\n\n- [A](/quest/m0/a.md)\n`), + ); + const out = await quest(tree, "mv", "quest/m0/a.md", "quest/m0/epic/a.md"); + expect(out.code).toBe(0); + expect(out.stdout).toBe("R quest/m0/a.md -> quest/m0/epic/a.md\nM quest/m0/epic/README.md\n"); + expect(tree.read("quest/m0/epic/README.md")).not.toContain("a.md"); + expect(staged(tree)).toEqual(["M quest/m0/epic/README.md", "R quest/m0/a.md -> quest/m0/epic/a.md"]); + tree.accepts(); + }); + test("moves an epic into a new directory", async () => { const tree = Tree.committed(); const out = await quest(tree, "mv", "quest/m0/epic", "quest/m1/later/epic"); @@ -462,9 +500,8 @@ describe("quest check --fix", () => { expect(tree.read("quest/m0/epic/three.md")).toBe(before); }); - // Trees from before directory membership keep their child lists until migrated: - // finishing a quest drops only its own entry. - test("drops only the finished quest's entry from a child list", async () => { + // Trees from before epics stopped listing their children migrate with one run. + test("strips the child lists of the root, milestones, and epics", async () => { const tree = Tree.baseline() .append("quest/README.md", "\n## Required\n\n- [M0](/quest/m0/README.md)\n") .append("quest/m0/README.md", "\n## Required\n\n- [Epic](/quest/m0/epic/README.md)\n") @@ -473,15 +510,14 @@ describe("quest check --fix", () => { "\n## Required\n\n- [One](/quest/m0/epic/one.md)\n- [Two](/quest/m0/epic/two.md)\n", ); committed(tree); - expect(await quest(tree, "rm", "quest/m0/epic/one.md")).toEqual({ + expect(await quest(tree, "check", "--fix")).toEqual({ code: 0, - stdout: "D quest/m0/epic/one.md\nM quest/m0/epic/README.md\nM quest/m0/epic/two.md\n", + stdout: "M quest/README.md\nM quest/m0/README.md\nM quest/m0/epic/README.md\nquest: 5 documents ok\n", stderr: "", }); - expect(tree.read("quest/m0/epic/README.md")).toBe( - `${EPIC_README}\n## Required\n\n- [Two](/quest/m0/epic/two.md)\n`, - ); - expect(await quest(tree, "check", "--fix")).toEqual({ code: 0, stdout: "quest: 4 documents ok\n", stderr: "" }); + expect(tree.read("quest/README.md")).toBe(ROOT_README); + expect(tree.read("quest/m0/README.md")).toBe(M0_README); + expect(tree.read("quest/m0/epic/README.md")).toBe(EPIC_README); }); test("fails outside a git work tree", async () => { diff --git a/test/tree.test.ts b/test/tree.test.ts index 2645d3e2..1127a77d 100644 --- a/test/tree.test.ts +++ b/test/tree.test.ts @@ -321,27 +321,19 @@ describe("epics", () => { .rejects("quest/m0/loose/deeper/README.md: sits in quest/m0/loose, which has no README.md"); }); - // Trees from before directory membership still list each epic's children. - // Such a link is membership, so it counts once. + // An epic already waits for everything in its directory, so a link to any of + // it is the child list this format retired; `quest check --fix` drops it. test.each([ ["quest/README.md", "/quest/m0/README.md"], ["quest/m0/README.md", "/quest/m0/epic/README.md"], + ["quest/m0/README.md", "/quest/m0/epic/one.md"], ["quest/m0/epic/README.md", "/quest/m0/epic/one.md"], - ])("%s requiring its child %s", (readme, target) => { - const listed = tree().append(readme, `\n## Required\n\n- [Held](${target})\n`); - listed.accepts(); - expect(listed.ready()).toEqual(tree().ready()); - expect(listed.blockers(readme)).toEqual(tree().blockers(readme)); - }); - - // Only a child stands in for a link: a deeper quest, or a link to no quest, - // stays a blocker, as on a tree that listed children. - test.each([ - ["/quest/m0/epic/one.md", "quest/m0/epic/one.md"], - ["/quest/m0/gone.md", "Gone"], - ])("quest/m0/README.md requiring %s, which is not its child", (target, rendered) => { - const t = tree().append("quest/m0/README.md", `\n## Required\n\n- [Gone](${target})\n`); - expect(t.blockers("quest/m0/README.md")[0]).toBe(rendered); + ])("%s requiring %s, which it holds", (readme, target) => { + const t = tree().append(readme, `\n## Required\n\n- [Held](${target})\n`); + expect(t.findings()).toHaveLength(1); + t.rejects( + `requires ${target}, which this epic holds; an epic waits for everything in its directory, so remove the entry (quest check --fix does)`, + ); }); test("a quest required twice", () => { @@ -422,12 +414,14 @@ describe("finished milestones", () => { .accepts(); }); - // The root's listing of its milestones is membership, not a blocker. - test("the root's link to a finished milestone is allowed", () => { - tree() + // The root holds every milestone, so its link to one is that finding alone. + test("the root's link to a finished milestone is one finding", () => { + const t = tree() .append("quest/README.md", "\n## Required\n\n- [M0](/quest/m0/README.md)\n") - .remove("quest/m0/epic") - .accepts(); + .remove("quest/m0/epic"); + expect(t.findings()).toEqual([ + "quest/README.md:9: requires /quest/m0/README.md, which this epic holds; an epic waits for everything in its directory, so remove the entry (quest check --fix does)", + ]); }); test("removing the link readies the quest", () => { diff --git a/test/unblocked.test.ts b/test/unblocked.test.ts index 817b3b6b..5a3cfef5 100644 --- a/test/unblocked.test.ts +++ b/test/unblocked.test.ts @@ -47,12 +47,6 @@ test("deduplicates diamonds and counts work that still has another prerequisite" ]); }); -test("an epic's list of its own children is membership, not dependents", () => { - const spec = { "m0/epic/a.md": [], "m0/epic/b.md": ["m0/epic/a.md"], "m0/zero.md": [] }; - const listed = graph({ ...spec, "m0/epic/README.md": ["m0/epic/a.md", "m0/epic/b.md"] }); - expect(unblocked(listed)).toEqual(unblocked(graph(spec))); -}); - test("does not score membership, but reaches real dependents of nested epics", () => { const docs = graph({ "m0/epic/nested/a.md": [],