This is an automated email from the ASF dual-hosted git repository. spmallette pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
commit 8e5fcba5f8292380d281af8eb5d73bec20438872 Author: Stephen Mallette <[email protected]> AuthorDate: Tue Oct 13 11:37:13 2026 -0400 Share record beads across roots and resolve the session root from its artifact Assisted-by: Claude Code:claude-opus-5-5 --- .beads/PRIME.md | 65 ++++++++++++----- .skills/tinker-review/references/enrichment-cli.md | 2 +- .skills/tinker-review/references/schema.md | 15 ++-- .skills/tinker-review/scripts/discovery/beads.js | 81 +++++++++++++--------- .skills/tinker-review/scripts/enrichment/api.js | 7 +- .../tinker-review/scripts/graph/populate-beads.js | 10 ++- .skills/tinker-review/scripts/renderer/render.js | 15 ++-- .skills/tinker-review/test/beads.test.js | 47 ++++++++++--- bin/beads-commits.py | 55 +++++++++------ 9 files changed, 196 insertions(+), 101 deletions(-) diff --git a/.beads/PRIME.md b/.beads/PRIME.md index 7ff9cda6ab..4d66210e9d 100644 --- a/.beads/PRIME.md +++ b/.beads/PRIME.md @@ -106,13 +106,28 @@ comment. Every session works under one **root bead**. Find it before writing code. +**When the operator names an artifact** — JIRA, PR, proposal, dev@ thread — its record's +parents are the candidates (section 6): + +```bash +bd search tp --external-contains <ref> --status all # the record; keep the exact match +bd dep list <record> -t parent-child --json # its roots; keep open/in_progress +``` + +- Exactly one: bind to it and say so in one line. +- Several: list them, most recently updated first, and ask which one. +- None open: ask whether to reopen one or start a new root. + +**With no artifact, or no record for it yet**, list every candidate: + ```bash bd list --status=all --json # filter client-side: no parent, open/in_progress bd children <root> # recursive — the whole subtree (--json: direct children only) ``` - Show the operator open/`in_progress` beads with **no parent**, most recently updated - first, and ask which one. That is your root for this session. + first, and ask which one. That is your root for this session. Ask even when only one is + open: it need not be this session's work. - **Read `bd children <root>` before resuming work.** It is the only thing that makes you notice a bead the work has since outgrown. - **If no root is selected, you are starting something new — create the root before writing @@ -208,14 +223,15 @@ unchanged into 3.8-dev and master, so what is recorded then stays correct. **Then suggest, confirm, record.** `--suggest` finds the commits from the root's own JIRA and PR records, lists what else landed in the same window, and writes nothing. Show what it returns, take the operator's corrections, and only then run the recording form. It also writes a record for each merged PR GitHub ties to those -commits, or relates the root to one another root already holds. It refuses any sha not reachable from `origin/<branch>`, so a refusal +commits, or makes the root a parent of one another root already holds. It refuses any sha not reachable from `origin/<branch>`, so a refusal means the work has not actually landed — say so and leave the root open. **An `epic` root needs `bd close <root> --force`.** The commit record just written is a child and is pinned, and the gate counts only `closed` children as done, so the record is what blocks the close. Do not reorder the two steps to dodge it: the record write is what refuses a sha unreachable from `origin/<branch>`, and closing first would resolve the root -before that check runs. `task` and `feature` roots do not gate. +before that check runs. `task` and `feature` roots do not gate. An epic that shares a record +another root has already pinned needs `--force` for the same reason. **Pin every bead in the subtree** — root, decisions, records, tasks. No judgment about which ones matter: the work shipped, so all of it is the project's history. Show the @@ -266,30 +282,45 @@ a list, and `bd ready` cannot tell you anything useful about it. `--description`; do not reach for them. Use design only for a specific reason description cannot serve, and state that reason in the description. - **`record` beads** hold external artifacts — JIRA, PR, dev@ thread, proposal, and the - commits a landing merged as. The ticket, URL or `owner/repo@sha` goes in `--external-ref`, - which identifies the kind as well, and only a record carries one. Create each as a child of - the **root** (`--parent <root>`), not of any other bead, and pin it. `bin/beads-commits.py` - finds a root's JIRA and PR records among its children, so a record attached any other way - is invisible to `--suggest`. + commits a landing merged as. The artifact goes in `--external-ref`, in exactly this form — + lookups match the ref exactly, so any other spelling of the same artifact is never found: + + | Kind | Form | + |---|---| + | JIRA | `TINKERPOP-NNNN` | + | PR | `apache/tinkerpop#N` | + | Commit | `apache/tinkerpop@<sha>` | + | dev@ thread | URL | + | Proposal | file name in `docs/src/dev/future/`, e.g. `proposal-equality-1.asciidoc` | + + The form identifies the kind as well, and only a record carries an external ref. **One artifact, one + record:** create it as a child of the **root** (`--parent <root>`), not of any other bead, + and pin it. When a second root needs an artifact that already has a record, make the record + that root's child too — `bd dep add <record> <root> -t parent-child` — never a duplicate. + `bin/beads-commits.py` finds a root's records among its children, so a record attached any + other way is invisible to `--suggest`. +- **A record may have several parents; nothing else may.** Records are leaves — nothing is + ever a child of a record. A record's roots are its `parent-child` edges, + `bd dep list <record> -t parent-child`. Its `parent` field reports only one of them, and + which one is undefined, so never read that field for a record, and never + `bd update --parent` one — it replaces only that edge. Every other bead keeps exactly one + parent. - **Search first — duplicates are the risk.** `bd query` has no field for external refs, and `bd search <ref> --status all` finds a ticket id but not a URL. For any ref, `bd search tp --external-contains <ref> --status all` works — `tp` matches every bead id. - The PR record is written at merge (section 4), not by hand. When a second root needs an - artifact that already has a record, add `bd dep add <root> <record> -t relates-to` rather than - a duplicate. `--suggest` on that root will not see it, so supply those commits by hand at - merge. + The PR record is written at merge (section 4), not by hand. - Records are the only link between beads and code; commit messages carry no bead ID. The commit record written at merge (section 4) is what makes that link work in reverse — - `bd query 'notes="<sha>"' --all` returns the record and its `parent`, for any commit in the - landing. Quote the sha: an unquoted one starting with a digit is lexed as a number and the + `bd query 'notes="<sha>"' --all` returns the record for any commit in the landing, and + `bd dep list <record> -t parent-child` the roots it landed for. Quote the sha: an unquoted one starting with a digit is lexed as a number and the query fails to parse. - **Write `relates-to` with `bd dep add -t relates-to`, never `bd dep relate`** — `relate` writes the edge in both directions, and every consumer reads one row per link. - Only **one dependency type per pair** — `blocks` and `discovered-from` cannot coexist between the same two beads. - Never construct a bead ID; use whatever `bd create` returns. Child IDs encode birth - position (`<root>.1.2`) but do not update on reparenting — traverse `parent` for truth, - treat the ID as a hint. + position (`<root>.1.2`) but do not update on reparenting — traverse `parent` (a record's + `parent-child` edges) for truth, treat the ID as a hint. --- @@ -363,7 +394,7 @@ bd dep add <a> <b> -t caused-by|relates-to|discovered-from|supersedes bd dep cycles # a plan with a cycle cannot execute bd update <id> --claim | -s pinned | --external-ref=TINKERPOP-NNNN bd search <text> -bd query 'notes="<sha>"' --all # which root did this commit come from; quote the sha +bd query 'notes="<sha>"' --all # which record holds this commit; quote the sha ``` Priority is `0-4` (0 = critical), never "high"/"medium"/"low". diff --git a/.skills/tinker-review/references/enrichment-cli.md b/.skills/tinker-review/references/enrichment-cli.md index 9eabe83a1d..f31c314b34 100644 --- a/.skills/tinker-review/references/enrichment-cli.md +++ b/.skills/tinker-review/references/enrichment-cli.md @@ -109,7 +109,7 @@ its own. Sorted so the dangerous ones surface first. ### listBeads The beads Phase 1 loaded for this PR — the roots reached from records naming the PR or its discussions, and everything under them — in reading order: the primary -root (the PR's own record) first, then owner and shared roots, each followed by +roots (of the PR's own record) first, then owner roots, each followed by its decisions, tasks and records. A chosen decision lists the rejected `alternatives` it beat and any decision it was `supersededBy`; `governs` shows code already linked with `linkBead`. Filter with `--root`, `--type` or `--status` diff --git a/.skills/tinker-review/references/schema.md b/.skills/tinker-review/references/schema.md index 08506be14f..d848eb7447 100644 --- a/.skills/tinker-review/references/schema.md +++ b/.skills/tinker-review/references/schema.md @@ -116,16 +116,19 @@ A bead from the reviewer's local beads database (`bd`), loaded because a record bead's external ref names one of the PR's artifacts (see `discovery/beads.js`). Every bead in the subtree of each root reached that way is loaded. `type` is bd's issue type (`epic`, `feature`, `task`, `bug`, `decision`, `record`, …); -`root` is the id of the root the bead belongs to. `rejected` is set only on +`root` is the id of the root the bead belongs to — for a record shared by several +loaded roots, the first of them; the others reach it through `child_of`, since a +shared record has one `child_of` edge per parent. `parent` is bd's scalar parent, +which on a shared record names only one parent: use `child_of`. `rejected` is set only on decisions — `true` marks the road not taken, and `description` then says what the option was, why it lost and what settled it. `externalRef` is set only on records. `labels` is the comma-joined label list. -`rootRole` is set only on roots: `primary` — the root of this PR's own record -(`apache/tinkerpop#N`); `owner` — the root a matched JIRA/dev@/proposal record -belongs to; `shared` — a root that links a matched record with `relates_to` +`rootRole` is set only on roots: `primary` — a root of this PR's own record +(`apache/tinkerpop#N`); `owner` — a root of a matched JIRA/dev@/proposal record (connected work). Query the PR's own work with `.has("rootRole", "primary")` -and a root's subtree with `.has("root", id)`. +and a root's subtree with `.has("root", id)`, adding +`.out("child_of").has("beadId", id)` for the records it shares. Status reads against the beads workflow: a root stays open until the work merges, and decisions are closed as they are written, so at PR time status is @@ -210,7 +213,7 @@ All `EXTRACTED` except `governs`: each is a fact in the beads database. | Edge | From | To | Meaning | |------|------|----|---------| -| `child_of` | Bead | Bead | bd's parent-child tree: the bead belongs to its parent | +| `child_of` | Bead | Bead | bd's parent-child tree: the bead belongs to its parent. A shared record has one per parent | | `records` | Bead (record) | Discussion | The record's external ref names this Discussion (the PR, a JIRA, a dev@ thread, a proposal). Only drawn to Discussions discovery created. | | `governs` | Bead | Function, Type or File | The bead — usually a decision — is about this code. Written in Phase 2 by `linkBead`, so it is `INFERRED` by default, unlike the rest of this table. | | `blocks`, `caused_by`, `relates_to`, `supersedes`, `discovered_from`, … | Bead | Bead | bd's other dependency types, carried over with `-` written as `_`. `relates_to` pairs a decision with the alternative it was chosen over; `caused_by` runs from a task to the decision that shaped it; `supersedes` from a newer decision to the one it replaces. Drawn only between loaded beads. | diff --git a/.skills/tinker-review/scripts/discovery/beads.js b/.skills/tinker-review/scripts/discovery/beads.js index 059e958725..d69ca9b9b5 100644 --- a/.skills/tinker-review/scripts/discovery/beads.js +++ b/.skills/tinker-review/scripts/discovery/beads.js @@ -21,10 +21,10 @@ // load their subtrees. The project's beads schema (.beads/PRIME.md section 6) // puts every external artifact — JIRA, PR, dev@ thread, proposal — on a `record` // bead that is a parent-child child of its root, and only records carry an -// external ref. A second root that needs the same artifact links to the existing -// record with a `relates-to` edge. So a record whose ref names one of the PR's -// artifacts leads to the owning root and any sharing roots, and everything -// reached that way is EXTRACTED. +// external ref. Each artifact has one record, and every root that needs it holds +// that record as a child, so a record may have several parents. A record whose +// ref names one of the PR's artifacts leads to every root it belongs to, and +// everything reached that way is EXTRACTED. // // The review reads beads and never writes them: every call except the sync // passes bd's --readonly flag, which makes bd itself refuse a write. @@ -50,11 +50,16 @@ export const BEADS_REQUIRED_MESSAGE = * rejected option was, why it lost and what settled it. * @property {boolean|null} rejected - On a decision, true marks the road not taken; null on other types. * @property {string[]} labels - * @property {string|null} parent - Parent bead id; null on a root. + * @property {string|null} parent - bd's scalar parent; null on a root. On a record with several + * parents it is only one of them, and which one is undefined: read `parents`. + * @property {string[]} parents - Every parent-child parent; more than one only on a shared record. * @property {string|null} externalRef - The artifact a record names (`TINKERPOP-NNNN`, - * `apache/tinkerpop#N`, `apache/tinkerpop@sha`, a dev@ URL); null on non-records. + * `apache/tinkerpop#N`, `apache/tinkerpop@sha`, a dev@ URL, a proposal file name); null on + * non-records. * @property {string|null} closedAt - * @property {string} root - Id of the root this bead belongs to. + * @property {string} root - Id of the first root, in `roots` order, this bead belongs to. + * @property {string[]} roots - Every loaded root this bead belongs to; more than one only on a + * shared record. * @property {{ to: string, type: string }[]} edges - Outgoing bd dependencies other than * parent-child (`blocks`, `caused-by`, `relates-to`, `supersedes`, `discovered-from`, ...). */ @@ -63,9 +68,9 @@ export const BEADS_REQUIRED_MESSAGE = * @typedef {object} BeadRoot * A root reached from a matched record. * @property {string} id - Root bead id. - * @property {"primary"|"owner"|"shared"} role - `primary`: the root of this PR's own record - * (`apache/tinkerpop#N`), the work the PR delivers. `owner`: the root a matched record belongs - * to. `shared`: a root that links a matched record with `relates-to` — connected work elsewhere. + * @property {"primary"|"owner"} role - `primary`: a root of this PR's own record + * (`apache/tinkerpop#N`), the work the PR delivers. `owner`: a root of another matched record — + * connected work. * @property {string[]} via - Ids of the matched records that reached this root. */ @@ -155,10 +160,18 @@ export function beadSeeds(pr, discussions = {}) { const seeds = [`apache/tinkerpop#${pr}`]; for (const j of discussions.jiras || []) seeds.push(j.id); for (const d of discussions.devList || []) if (d.found_in === "pr") seeds.push(stripTrailing(d.url)); - for (const p of discussions.proposals || []) if (p.matchedIn === "reference") seeds.push(p.path); + // A proposal record names its file, unique within docs/src/dev/future/ (PRIME.md section 6). + for (const p of discussions.proposals || []) if (p.matchedIn === "reference") seeds.push(p.path?.split("/").pop()); return [...new Set(seeds.filter(Boolean))]; } +// Every parent-child parent. bd's scalar `parent` names only one of a shared +// record's parents, so the edges are the truth. +function parentsOf(raw) { + const parents = (raw?.dependencies || []).filter((d) => d.type === "parent-child").map((d) => d.depends_on_id); + return parents.length > 0 ? parents : raw?.parent ? [raw.parent] : []; +} + function toBead(raw) { return { id: raw.id, @@ -169,9 +182,11 @@ function toBead(raw) { rejected: raw.issue_type === "decision" ? raw.metadata?.rejected === true : null, labels: raw.labels || [], parent: raw.parent || null, + parents: parentsOf(raw), externalRef: raw.external_ref || null, closedAt: raw.closed_at || null, root: null, + roots: [], edges: (raw.dependencies || []) .filter((d) => d.type !== "parent-child") .map((d) => ({ to: d.depends_on_id, type: d.type })), @@ -185,16 +200,17 @@ function toBead(raw) { * keeping exact matches (the flag is a substring match). The positional query * is also applied, as a title/ID match, so it is the project's ID prefix — which * every bead matches — rather than the ref, which a record's title need not repeat. - * 2. Each matched record's top-most ancestor is its owner root; every bead that - * links the record with `relates-to` leads to a shared root. A PR record's owner - * root is primary. Every root reached is loaded — connected work is context. + * 2. Each matched record's parents, followed to their top-most ancestors, are its + * roots. A PR record's roots are primary, every other record's are owners. + * Every root reached is loaded — connected work is context. * 3. Load each root's subtree. `bd children --json` returns one level only (its * text output is the tree), so the candidates come from * `bd dep tree <root> --direction up`, which walks every * dependent in one call — including beads outside the subtree that merely * relate to one inside it. One `bd list --id` call then supplies each - * candidate's real `parent` and full fields, and only beads whose parent - * chain reaches the root are kept. Comments are read for the roots and + * candidate's parent-child edges and full fields, and only beads whose parent + * chain reaches the root are kept. A shared record is kept once, with every + * root it reached in `roots`. Comments are read for the roots and * decisions that have any. * * @param {object} params @@ -225,19 +241,17 @@ export async function discoverBeads({ seeds, prRef, run }) { const rows = (await run(["list", "--id", missing.join(","), "--all", "-n", "0", "--flat"])) || []; for (const r of rows) fetched.set(r.id, r); }; - const topOf = async (id) => { - let cur = id; - const seen = new Set(); - for (;;) { - await fetch([cur]); - const parent = fetched.get(cur)?.parent; - if (!parent || seen.has(parent)) return cur; - seen.add(cur); - cur = parent; - } + const topsOf = async (id, seen = new Set()) => { + seen.add(id); + await fetch([id]); + const parents = parentsOf(fetched.get(id)).filter((p) => !seen.has(p)); + if (parents.length === 0) return [id]; + const tops = []; + for (const p of parents) for (const t of await topsOf(p, seen)) if (!tops.includes(t)) tops.push(t); + return tops; }; - const RANK = { primary: 0, owner: 1, shared: 2 }; + const RANK = { primary: 0, owner: 1 }; const roots = new Map(); const reach = (rootId, role, record) => { const r = roots.get(rootId) || { id: rootId, role, via: [] }; @@ -247,14 +261,12 @@ export async function discoverBeads({ seeds, prRef, run }) { }; for (const { seed, record } of result.matches) { - reach(await topOf(record), seed === prRef ? "primary" : "owner", record); - const sharers = (await run(["dep", "list", record, "--direction", "up", "-t", "relates-to"])) || []; - for (const s of sharers) reach(await topOf(s.id), "shared", record); + for (const top of await topsOf(record)) reach(top, seed === prRef ? "primary" : "owner", record); } result.roots = [...roots.values()].sort((a, b) => RANK[a.role] - RANK[b.role]); + const loaded = new Map(); for (const { id: rootId } of result.roots) { - if (result.beads.some((b) => b.root === rootId)) continue; const tree = (await run(["dep", "tree", rootId, "--direction", "up"])) || []; await fetch(tree.map((t) => t.id)); const inSubtree = new Set([rootId]); @@ -262,7 +274,7 @@ export async function discoverBeads({ seeds, prRef, run }) { while (grew) { grew = false; for (const { id } of tree) { - if (!inSubtree.has(id) && inSubtree.has(fetched.get(id)?.parent)) { + if (!inSubtree.has(id) && parentsOf(fetched.get(id)).some((p) => inSubtree.has(p))) { inSubtree.add(id); grew = true; } @@ -270,9 +282,12 @@ export async function discoverBeads({ seeds, prRef, run }) { } for (const id of inSubtree) { const raw = fetched.get(id); - if (raw) result.beads.push({ ...toBead(raw), root: rootId }); + if (!raw) continue; + if (!loaded.has(id)) loaded.set(id, { ...toBead(raw), root: rootId }); + loaded.get(id).roots.push(rootId); } } + result.beads = [...loaded.values()]; for (const b of result.beads) { if (b.id !== b.root && b.type !== "decision") continue; diff --git a/.skills/tinker-review/scripts/enrichment/api.js b/.skills/tinker-review/scripts/enrichment/api.js index cd044cad1f..0068ec4f9c 100644 --- a/.skills/tinker-review/scripts/enrichment/api.js +++ b/.skills/tinker-review/scripts/enrichment/api.js @@ -183,7 +183,7 @@ export async function listExternalRefs(g) { /** * The beads Phase 1 loaded for this PR — the author's recorded plan, decisions * and the alternatives they turned down — so the reviewer can read the change - * against them. Roots come first (primary, then owner, then shared), then each + * against them. Roots come first (primary, then owner), then each * root's decisions, tasks and records. A decision lists the rejected * alternatives it was chosen over (a `relates-to` edge to a rejected decision) and any * decision that supersedes it; judge the code against the newest decision in a @@ -199,7 +199,8 @@ export async function listExternalRefs(g) { */ export async function listBeads(g, filter = {}) { let t = g.V().hasLabel("Bead"); - if (filter.root) t = t.has("root", filter.root); + // A shared record's `root` names only its first root; the others reach it by child_of. + if (filter.root) t = t.or(__.has("root", filter.root), __.out("child_of").has("beadId", filter.root)); if (filter.type) t = t.has("type", filter.type); if (filter.status) t = t.has("status", filter.status); const rows = await t.project("bead", "alternatives", "supersededBy", "governs") @@ -236,7 +237,7 @@ export async function listBeads(g, filter = {}) { return bead; }); - const ROLE = { primary: 0, owner: 1, shared: 2 }; + const ROLE = { primary: 0, owner: 1 }; const TYPE = { decision: 1, record: 3 }; const roleOf = new Map(beads.filter((b) => b.rootRole).map((b) => [b.beadId, ROLE[b.rootRole]])); const rank = (b) => [roleOf.get(b.root) ?? 3, b.root, b.rootRole ? 0 : (TYPE[b.type] ?? 2), b.beadId]; diff --git a/.skills/tinker-review/scripts/graph/populate-beads.js b/.skills/tinker-review/scripts/graph/populate-beads.js index 650603f650..6acd0011cf 100644 --- a/.skills/tinker-review/scripts/graph/populate-beads.js +++ b/.skills/tinker-review/scripts/graph/populate-beads.js @@ -41,6 +41,8 @@ export function discussionUrlForRef(ref) { if (pr) return `https://github.com/apache/tinkerpop/pull/${pr[1]}`; if (/^TINKERPOP-\d+$/.test(ref)) return `https://issues.apache.org/jira/browse/${ref}`; if (ref.includes("@")) return null; + // A proposal record names its file; its Discussion carries the repo path. + if (!ref.includes("/")) return `docs/src/dev/future/${ref}`; return ref; } @@ -49,7 +51,8 @@ const edgeLabel = (type) => type.replace(/-/g, "_"); /** * Load the beads found by discoverBeads() into the graph: a Bead vertex per - * bead, `child_of` for the parent-child tree, bd's other dependencies carried + * bead, `child_of` for the parent-child tree (one per parent, so a shared record + * has one to each of its roots), bd's other dependencies carried * over as edges between loaded beads, and `records` from a record bead to the * Discussion its external ref names. Every edge is EXTRACTED: each one is a * fact in the beads database. @@ -94,11 +97,12 @@ export async function populateBeads(g, beads) { }; for (const b of beads.beads) { - if (b.parent && loaded.has(b.parent)) { + for (const parent of b.parents || (b.parent ? [b.parent] : [])) { + if (!loaded.has(parent)) continue; await push( g.V().hasLabel("Bead").has("beadId", b.id) .addE("child_of").property("confidence", CONFIDENCE.EXTRACTED) - .to(bead(b.parent)), + .to(bead(parent)), "childOf", ); } diff --git a/.skills/tinker-review/scripts/renderer/render.js b/.skills/tinker-review/scripts/renderer/render.js index a986db4879..e45d342926 100644 --- a/.skills/tinker-review/scripts/renderer/render.js +++ b/.skills/tinker-review/scripts/renderer/render.js @@ -211,8 +211,7 @@ function renderContext(disc) { const ROLE_TEXT = { primary: "This PR's work", - owner: "Owns a linked record", - shared: "Connected work", + owner: "Connected work", }; // Where an external ref points: a PR, a JIRA, a commit, a URL; null for a path. @@ -293,7 +292,11 @@ function renderBeadRoot(root, members, comments, beadsById, role, via) { const human = members.filter((b) => (b.labels || []).includes("human") && !["closed", "pinned"].includes(b.status)); const recordList = records.length > 0 - ? `<p><strong>Records:</strong> ${records.map((r) => beadAnchor(r, `${externalRefHtml(r.externalRef)}${via.includes(r.id) ? " <em>(matched)</em>" : ""}`)).join(", ")}</p>` : ""; + ? `<p><strong>Records:</strong> ${records.map((r) => { + const text = `${externalRefHtml(r.externalRef)}${via.includes(r.id) ? " <em>(matched)</em>" : ""}`; + // A shared record appears under each of its roots; only the first holds its anchor. + return r.root === root.id ? beadAnchor(r, text) : text; + }).join(", ")}</p>` : ""; const commentList = (comments[root.id] || []).length > 0 ? `<details><summary>Root comments (${comments[root.id].length})</summary><ul class="bead-list">${comments[root.id].map((c) => `<li>${beadText(c.text)}</li>`).join("")}</ul></details>` : ""; const humanList = human.length > 0 @@ -338,7 +341,7 @@ function renderProjectMemory(beads) { const cards = roots.map((r) => { const root = beadsById.get(r.id); if (!root) return ""; - return renderBeadRoot(root, all.filter((b) => b.root === r.id), beads.comments || {}, beadsById, r.role, r.via || []); + return renderBeadRoot(root, all.filter((b) => (b.roots || [b.root]).includes(r.id)), beads.comments || {}, beadsById, r.role, r.via || []); }); return [intro, ...cards].join("\n "); } @@ -348,11 +351,11 @@ function renderBeadsAppendix(beads) { if (all.length === 0) return ""; const rows = all.map((b) => { const decision = b.rejected === true ? "not taken" : b.rejected === false ? "chosen" : ""; - return `<tr><td class="fn-name"><a href="#bead-${esc(b.id)}">${esc(b.id)}</a></td><td>${esc(b.type)}${decision ? ` (${decision})` : ""}</td><td>${esc(b.status)}</td><td><code>${esc(b.root)}</code></td><td>${esc(b.title)}</td></tr>`; + return `<tr><td class="fn-name"><a href="#bead-${esc(b.id)}">${esc(b.id)}</a></td><td>${esc(b.type)}${decision ? ` (${decision})` : ""}</td><td>${esc(b.status)}</td><td>${(b.roots || [b.root]).map((id) => `<code>${esc(id)}</code>`).join(" ")}</td><td>${esc(b.title)}</td></tr>`; }).join("\n "); return ` <h3>Beads</h3> - <p class="section-intro">Every bead loaded from the roots in <a href="#context">Project Memory</a>. Each is a <code>Bead</code> vertex in the graph; query a root's subtree with <code>.has("root", id)</code>.</p> + <p class="section-intro">Every bead loaded from the roots in <a href="#context">Project Memory</a>. Each is a <code>Bead</code> vertex in the graph; query a root's subtree with <code>.has("root", id)</code>, plus <code>.out("child_of").has("beadId", id)</code> for the records it shares.</p> <table class="gap-table"> <thead><tr><th>Bead</th><th>Type</th><th>Status</th><th>Root</th><th>Title</th></tr></thead> <tbody>\n ${rows}\n </tbody> diff --git a/.skills/tinker-review/test/beads.test.js b/.skills/tinker-review/test/beads.test.js index 086a07da26..ff1b8a473e 100644 --- a/.skills/tinker-review/test/beads.test.js +++ b/.skills/tinker-review/test/beads.test.js @@ -31,7 +31,8 @@ import { render } from "../scripts/renderer/render.js"; // A small beads database shaped like PRIME.md section 6. Each entry is the row // bd list --json returns; `deps` are outgoing dependencies as [target, type]. -function bead(id, type, { parent = null, ref = null, rejected, deps = [], comments = 0, title = id } = {}) { +// `also` names further parent-child parents, which bd's scalar `parent` omits. +function bead(id, type, { parent = null, also = [], ref = null, rejected, deps = [], comments = 0, title = id } = {}) { return { id, issue_type: type, status: type === "decision" ? "closed" : "open", title, description: `reasoning of ${id}`, labels: ["gremlin-core"], parent, @@ -39,6 +40,7 @@ function bead(id, type, { parent = null, ref = null, rejected, deps = [], commen metadata: rejected === undefined ? null : { rejected }, dependencies: [ ...(parent ? [{ depends_on_id: parent, type: "parent-child" }] : []), + ...also.map((to) => ({ depends_on_id: to, type: "parent-child" })), ...deps.map(([to, t]) => ({ depends_on_id: to, type: t })), ], }; @@ -46,14 +48,15 @@ function bead(id, type, { parent = null, ref = null, rejected, deps = [], commen const DB = [ bead("tp-a", "feature", { comments: 2 }), - bead("tp-a.1", "record", { parent: "tp-a", ref: "TINKERPOP-1234", title: "PR and JIRA" }), + // Shared with tp-b: one record, a child of both roots. + bead("tp-a.1", "record", { parent: "tp-a", also: ["tp-b"], ref: "TINKERPOP-1234", title: "PR and JIRA" }), bead("tp-a.2", "record", { parent: "tp-a", ref: "https://lists.apache.org/thread/abc", title: "dev@ thread: topic" }), bead("tp-a.3", "decision", { parent: "tp-a", rejected: false, deps: [["tp-a.4", "relates-to"]], comments: 1 }), bead("tp-a.4", "decision", { parent: "tp-a", rejected: true }), bead("tp-a.5", "task", { parent: "tp-a", deps: [["tp-a.3", "caused-by"]] }), bead("tp-a.5.1", "task", { parent: "tp-a.5" }), // A second root that shares tp-a's JIRA record instead of duplicating it. - bead("tp-b", "epic", { deps: [["tp-a.1", "relates-to"]] }), + bead("tp-b", "epic"), bead("tp-b.1", "record", { parent: "tp-b", ref: "apache/tinkerpop#99" }), bead("tp-b.2", "decision", { parent: "tp-b", rejected: false }), // Unrelated work that relates to a bead inside tp-a: reachable by bd dep tree @@ -91,9 +94,6 @@ function fakeBd(calls = []) { && (b.external_ref || "").toLowerCase().includes(ext)); } case "dep": - if (args[1] === "list") { - return dependentsOf(args[2]).filter((b) => b.dependencies.some((d) => d.depends_on_id === args[2] && d.type === opt("-t"))); - } if (args[1] === "tree") { const seen = new Set([args[2]]); const out = [byId.get(args[2])]; @@ -126,7 +126,7 @@ test("seeds are the PR plus only the artifacts the PR names explicitly", () => { "apache/tinkerpop#42", "TINKERPOP-1234", "https://lists.apache.org/thread/abc", - "docs/src/dev/future/proposal-x.asciidoc", + "proposal-x.asciidoc", ]); }); @@ -150,19 +150,29 @@ test("the owning root and every sharing root are loaded; the PR's own root is pr ]); }); -test("a shared record leads to the sharing root as shared", async () => { +test("a shared record leads to every root it is a child of", async () => { const r = await discoverBeads({ seeds: ["TINKERPOP-1234"], prRef: "apache/tinkerpop#7", run: fakeBd() }); assert.deepEqual(r.roots.map(({ id, role }) => ({ id, role })), [ { id: "tp-a", role: "owner" }, - { id: "tp-b", role: "shared" }, + { id: "tp-b", role: "owner" }, ]); }); +test("a shared record is loaded once, under every root it reached", async () => { + const r = await discoverBeads({ seeds: ["TINKERPOP-1234"], run: fakeBd() }); + const shared = r.beads.filter((b) => b.id === "tp-a.1"); + assert.equal(shared.length, 1); + assert.deepEqual(shared[0].parents, ["tp-a", "tp-b"]); + assert.deepEqual(shared[0].roots, ["tp-a", "tp-b"]); + assert.equal(shared[0].root, "tp-a"); + assert.deepEqual(r.beads.filter((b) => b.roots.includes("tp-b")).map((b) => b.id).sort(), ["tp-a.1", "tp-b", "tp-b.1", "tp-b.2"]); +}); + test("the subtree includes grandchildren and excludes beads that only relate to it", async () => { const r = await discoverBeads({ seeds: ["https://lists.apache.org/thread/abc"], run: fakeBd() }); const ids = r.beads.map((b) => b.id).sort(); assert.deepEqual(ids, ["tp-a", "tp-a.1", "tp-a.2", "tp-a.3", "tp-a.4", "tp-a.5", "tp-a.5.1"]); - assert.ok(r.beads.every((b) => b.root === "tp-a")); + assert.ok(r.beads.every((b) => b.root === "tp-a" && b.roots.join() === "tp-a")); }); test("beads carry the rejected flag, external ref and non-tree edges", async () => { @@ -257,6 +267,23 @@ test("Project Memory shows the root, its records, plan and decisions with altern } }); +test("Project Memory lists a shared record under each root, anchored once", () => { + const shared = { + ...found, + roots: [{ id: "tp-a", role: "primary", via: ["tp-a.1"] }, { id: "tp-b", role: "primary", via: ["tp-a.1"] }], + beads: [ + shaped("tp-a", "feature", { status: "open" }), + shaped("tp-b", "epic", { status: "open", root: "tp-b", roots: ["tp-b"] }), + shaped("tp-a.1", "record", { parent: "tp-a", parents: ["tp-a", "tp-b"], roots: ["tp-a", "tp-b"], externalRef: "apache/tinkerpop#99" }), + ], + comments: {}, + }; + const html = render({ meta, discussions: { beads: shared } }); + assert.equal(html.match(/apache\/tinkerpop#99<\/a> <em>\(matched\)<\/em>/g).length, 2, "listed under both roots"); + assert.equal(html.match(/<span id="bead-tp-a\.1">/g).length, 1, "one anchor per bead"); + assert.match(html, /<td><code>tp-a<\/code> <code>tp-b<\/code><\/td>/, "the appendix names both roots"); +}); + test("Project Memory says the data wasn't available when nothing matched, and when the sync failed", () => { const html = render({ meta, diff --git a/bin/beads-commits.py b/bin/beads-commits.py index a7c231e205..8f3cc0d529 100755 --- a/bin/beads-commits.py +++ b/bin/beads-commits.py @@ -37,7 +37,9 @@ those commits -- apache/tinkerpop#3578 -- unless one already exists. A PR is usually opened after the root, so nothing earlier in the workflow is reliably there to write its record. Recording the landing is the one step every root passes through, and by then GitHub knows which PR carried the commits. A PR -recorded under another root is related to this one rather than duplicated. +recorded under another root is shared rather than duplicated: this root becomes +another of its parents (PRIME.md section 6). So is a landing, when two roots +record the same commits. The shas go in `notes` because that is a free-text field bd can query. `bd search` matches title, id and some external refs but never notes, and @@ -48,7 +50,8 @@ pasted from `git log --oneline` is a prefix of it, so both forms of the query fi query: most shas begin with a digit, and an unquoted one is lexed as a number and fails to parse, the same way an unquoted version label does. - bd query 'notes="e1ca7ea3e4"' --all --json # -> the record, and its parent root + bd query 'notes="e1ca7ea3e4"' --all --json # -> the record + bd dep list <record> -t parent-child --json # -> the roots it landed for That is the reverse direction, from a line of `git log` back to the reasoning. The forward direction is `bd show <record>`. @@ -138,6 +141,26 @@ def bd_show(bead_id): return found[0] if found else None +def roots_of(record): + """Every root a record belongs to. + + A record may have several parent-child parents, and its scalar `parent` field + reports only one of them, so membership is read from the edges. + """ + return {dep["id"] for dep in as_list(bd("dep", "list", record, "-t", "parent-child"))} + + +def share(record, root, what, dry_run): + """Make root a parent of a record another root already holds.""" + if root in roots_of(record): + print(f"{record} already records {what} under {root}") + elif dry_run: + print(f"--dry-run: would add {root} as a parent of {record} ({what})") + else: + bd("dep", "add", record, root, "-t", "parent-child") + print(f"added {root} as a parent of {record}, which already records {what}") + + def pr_merge_commit(number): """The merge commit for a PR, or None when it is unmerged or unreachable.""" proc = subprocess.run( @@ -169,25 +192,13 @@ def merged_prs(shas, slug): def record_pr(root, ref, dry_run): - """Make sure the PR record exists, under this root or related to it.""" + """Make sure the PR record exists and this root is one of its parents.""" # bd search does not match a ref of this shape reliably, so filter client-side. existing = [bead for bead in as_list(bd("list", "--all", "--limit", "0")) if bead.get("issue_type") == "record" and bead.get("external_ref") == ref] - for bead in existing: - if bead.get("parent") == root: - print(f"{bead['id']} already records {ref} under {root}") - return if existing: - # PRIME.md section 6: a second root relates to the existing record. - bead = existing[0] - related = (bd_show(root) or {}).get("dependencies") or [] - if any(dep.get("id") == bead["id"] for dep in related): - print(f"{bead['id']} records {ref} under {bead.get('parent')}; already related to {root}") - elif dry_run: - print(f"--dry-run: would relate {root} to {bead['id']} ({ref})") - else: - bd("dep", "add", root, bead["id"], "-t", "relates-to") - print(f"related {root} to {bead['id']}, which records {ref} under {bead.get('parent')}") + # PRIME.md section 6: one record per artifact, shared by every root that needs it. + share(existing[0]["id"], root, ref, dry_run) return if dry_run: print(f"--dry-run: would create a record for {ref} under {root} and pin it") @@ -339,13 +350,13 @@ def chronological(shas): return sorted(shas, key=lambda sha: int(git("log", "-1", "--format=%ct", sha))) -def existing_record(root, tip): - """The record for this landing, if a previous run already wrote it.""" +def existing_record(tip): + """The record for this landing, if a previous run -- for any root -- wrote it.""" # The value has to be quoted inside the query. A sha that starts with a digit # is otherwise lexed as a number and the parse fails -- the same trap # release.asciidoc documents for version labels, and most shas start with one. for bead in as_list(bd("query", f'notes="{tip}"', "--all")): - if bead.get("parent") == root: + if bead.get("issue_type") == "record": return bead return None @@ -386,9 +397,9 @@ def main(): # --pr already names the pull request; otherwise ask GitHub which carried these. prs = [args.pr] if args.pr else merged_prs(shas, slug) - found = existing_record(args.root, tip) + found = existing_record(tip) if found: - print(f"{found['id']} already records this landing under {args.root}") + share(found["id"], args.root, ref, args.dry_run) else: print(f"{ref}\n{notes}\n") if args.dry_run:
