diff --git a/.agents/skills/proof/SKILL.md b/.agents/skills/proof/SKILL.md index 18ba10ed..c8a105b3 100644 --- a/.agents/skills/proof/SKILL.md +++ b/.agents/skills/proof/SKILL.md @@ -129,7 +129,9 @@ Critical semantics: `superseded_by` and `invalidated_by` reverse projections; `derives_from` writes no reverse edge. - External sources: create a `WriteCitation` record (its body may be a URL, - with optional `blob` and `role` fields), then add + with optional `blob` and `role` fields; `role: code` with a body such as + `src/a.ts#symbol@sha` ties the citing records to that code for + `proof find --path`), then add `cites: [""]` when creating an Issue, Finding, Decision, Constraint, or Risk. Both the Citation in `cites` and the Blob in `Citation.blob` must belong to the same Effort as the record that links to @@ -194,7 +196,23 @@ Pick the read by what you need, in this order: 3. **Read one record in full: `proof get `.** On a compaction, `get` also says whether it is stale, and why, gives the exact number of records that changed since it was written, and lists up to 25 of them. -4. **Page through records: `proof records`.** `find` shows only the top +4. **Before opening code: `proof find --path `.** It + lists the records that govern that code, each at its chain head, with a + `via:` line naming the tie: a code Citation (`role: code`, body + `src/a.ts#symbol@sha`) the record cites, a commit touching the path with + a `Proof: ` trailer, or a body that names the path. Add words to + rank them. Run it before you change a file, so you build on the accepted + Decisions and Constraints instead of crawling the codebase or the graph. +5. **Before building on a Decision: `proof find --refs all`.** It overlays + the Proof records other branches change, without a checkout, tags each + card `main`, `only on `, or `differs on : vs ` + (with the draft PR when `proof branches --refresh-prs` has run), and ends + with a `CONFLICTS` block: two accepted Decisions on one question, one + Issue resolved two ways, a record superseded twice, a state two branches changed differently, a + retraction of something another branch relies on, two head compactions. + Without words it lists what the branches propose. It composes with + `--path` and `--effort`. +6. **Page through records: `proof records`.** `find` shows only the top hits, so never count from it; count from `state`. `--state` and `--status` match only the stored field. `--standing invalidated` (or `superseded`, `retracted`, any standing a card prints) lists records by @@ -213,6 +231,14 @@ byte cap). Zoom in with `get`, then Read/grep that digest — do not open flatbread proof find "tern history buffer precision" --effort flatbread proof find "shadow pool overflow" --as-of 2026-03-01T00:00:00Z +# The records that govern a file, folder, or symbol, before you open it +flatbread proof find --path src/render/shadows.ts +flatbread proof find "pool size" --path src/render/shadows.ts#allocate + +# What other branches propose, and where they conflict, without a checkout +flatbread proof find --refs all --effort +flatbread proof find "shadow pool" --refs feat/pool-v2,origin/feat/atlas + # Counts, open blockers, Risks, contested topics, dead evidence, lifecycles flatbread proof state @@ -273,7 +299,10 @@ server-side. `dec-adopt-a-bounded-status-briefing-fast-path-for-ef--kcw0rw39g3b2ym2h`). 2. **When you need a particular record or fact:** run `proof find` first, then `proof get` the hit whose card fits. Do not page through `records` - to look for it. **When you need a count, a list of open, closed, or + to look for it. **When you are about to change code:** run + `proof find --path ` first. **When you are about to build on a + Decision, or accept one:** run `proof find --refs all` (scoped with + `--effort`) and read its `CONFLICTS` block. **When you need a count, a list of open, closed, or rejected records, a status history, the blockers, or what rests on dead evidence:** run `proof state ` instead. 3. **When a browse digest shows `[…truncated]` and you need the body:** run diff --git a/.agents/skills/proof/reference.md b/.agents/skills/proof/reference.md index bf981882..81ec9a8b 100644 --- a/.agents/skills/proof/reference.md +++ b/.agents/skills/proof/reference.md @@ -254,6 +254,9 @@ flatbread proof relations --relations r1,r2 [--limit n] [--c flatbread proof blocking-decisions [consistency flags] flatbread proof brief [consistency flags] flatbread proof find "" [--effort | --all] [--kinds k1,k2] [--as-of iso] [--retracted exclude|include|only] [--limit n] [consistency flags] +flatbread proof find [""] --path [--refs r1,r2|all] [find flags] +flatbread proof find [""] --refs [find flags] +flatbread proof branches [--refresh-prs] flatbread proof state [consistency flags] flatbread proof cache prune flatbread proof serve [--socket .flatbread/proof/serve.sock] @@ -420,9 +423,123 @@ Each card has these lines: ``` The card header reads `find "" in ( records searched, matched, shown)`, then `matched: … | missing: …`. The envelope adds `cards` (that Markdown) and `find`: -`{"query","effort_id","searched","matched","shown","terms":{"matched","missing"},"efforts"?,"hits":[{"id","matched_id"?,"score"}]}`. +`{"query","effort_id","searched","matched","shown","terms":{"matched","missing"},"efforts"?,"hits":[{"id","matched_id"?,"score","via"?,"branch"?}],"path"?,"refs"?,"conflicts"?,"conflicts_total"?}`. `find` shows only the top hits; use `state` or `records` to count. +### `proof find --path`: the records that govern a piece of code + +`--path ` returns the records that govern that code, without +reading the code or the whole graph. Words are optional. The target is a +file or folder (relative to the current folder when it exists there, else +to the repository root), a symbol (`startFrame`, `startFrame()`, +`Planner.plan`), or both (`src/fuse.ts#budget`). A folder matches the files +under it. The repository root itself (`.`) is refused. Three sources tie a record to code, strongest first: + +1. **Code Citations:** a Citation with `role: code` whose body names the + code as ``, `#`, or `#@`. The + records that cite it govern the code, and so do the records it + `derives_from`; a code Citation nothing cites stands for itself. A file + anchor with no symbol covers every symbol in the file. Write one when a + Decision or Constraint is about specific code: + `{"type":"WriteCitation","effort":"","title":"Fuse budget","body":"src/fuse.ts#budget@1a2b3c4","role":"code"}`, + then put its id in the record's `cites`. +2. **Commit trailers:** commits that touch the path and carry one or more + `Proof: ` trailers (`git log` from the Proof root's repository; + skipped quietly outside git, and for a bare symbol). +3. **Body text:** a record body that names the path as a whole token + (`src/a.ts` does not match `src/a.tsx`), or the symbol as `name(`, + `obj.name(`, or `#name`. + +Each governed record resolves to its chain head, as `find` shows chains; +retracted records follow `--retracted`. With words, BM25 ranks the +governed set (records no word matches still list, after the matches). +Without words, the strongest tie ranks them, then the newest; the header +drops `score` and the `matched:` line. Default 5 cards, `--limit` up to 10. +Each card adds, after its title: + +```text + via: cites : src/fuse.ts#budget@1a2b3c4; commit 1a2b3c4 "" (on ); body names src/fuse.ts +``` + +The header reads `find [""] --path in ( governing, shown)`. + +### `proof find --refs`: what other branches propose + +`--refs feat/a,origin/feat/b` or `--refs all` overlays records from other +git refs without checking them out (`git ls-tree` and `git cat-file`, +cached by commit). No git command it runs fetches: in a partial clone, a +record whose blob is not on disk is left out, and the `refs:` line says +`N records not available locally`. `all` skips local and remote branches +whose Proof folder HEAD's history already had, looks at the 50 others with +the newest commits (the `refs:` line says so when there are more), and +keeps those that change a Proof record, at most 12. Of a local branch and +its remote-tracking ref, `all` keeps only the newer one when one contains +the other. A ref counts only for the records it changed since its merge +base with HEAD, so a branch that is merely behind adds nothing, and a +version already in HEAD's history does not count, so a branch left behind +after a squash merge adds nothing. When two refs hold different versions +of a record and one is the version at the two refs' own merge base, as +with a branch stacked on another, the older version is dropped. With no +merge base (a shallow clone), HEAD stands in for it, the `refs:` line says +`no merge base: shallow clone?`, and `git fetch --deepen=200` finds one. +The base is the working tree. In the combined graph, supersession and invalidation come +from the forward edges (`supersedes`, `invalidates`) of every version; the +stored `superseded_by` and `invalidated_by` on either side are ignored. An +Issue's `resolved_by` is the union over every version, so a Decision that +resolves a question only on a branch still shows `in: resolves`. With +words, `find` searches the combined graph; without words or `--path`, the +cards are the records the refs add or change, newest first. Each card adds +a `branch:` line: + +```text + branch: main (same as the working tree) + branch: only on feat/a (draft #12 by ana) (draft PR from prs.json) + branch: differs on feat/a: proposed vs accepted ( vs ) + branch: deleted on feat/a (the ref removed the record file) + branch: only on feat/b; rival heads: (only on feat/a) +``` + +The last form is a chain that two branches each supersede: the chain line +marks every head `(head)`, and no head replaces the other. + +A `refs:` line after the header names each ref, its commit, and how many +records it adds, changes, or deletes (Efforts counted apart). The cards end +with `CONFLICTS ()` and one `- ` line each (at most 12, then +`+n more`), or `CONFLICTS: none`. With `--effort`, +only that Effort's conflicts show. A conflict names its refs (`base` is the +working tree) and ids: + +- two accepted Decisions on one question Issue, tied to it by + `derives_from` or by the Issue's `resolved_by` on any side (a ref that + accepted one and rejected the base's accepted one is a proposal, not a + conflict); +- one Issue resolved by different records on two sides, when the line + above does not already name it; +- one record superseded by two different records; +- one id with a different stored `state` or `status` on two sides that + both changed it since they split (a change only one ref made shows on its + card's `branch:` line instead); +- a retraction on one side of a record another side relies on + (`derives_from`, `cites`, `evidence`), when that side added or changed + the relying record since they split; +- two head compactions for one Effort. + +`--refs` composes with `--path`, `--effort`, and `--all`; with `--path`, +commit trailers on the refs count too. The envelope's `find` adds `refs` +(`[{"name","sha","changed","pr"?}]`), `conflicts` (the same 12 the cards +list), and `conflicts_total`. Narrow with `--effort` or fewer refs to see +the rest. + +`proof branches` lists the refs `--refs all` would overlay, with their +pull requests. Pull requests come from a gitignored +`.flatbread/proof/prs.json` (an array of +`{number, headRefName, isDraft, author}`). A ref matches a pull request +whose `headRefName` is its branch, or is its branch with a remote name in +front (`origin/feat/a`). `proof branches --refresh-prs` +rewrites it from `gh pr list --json number,headRefName,isDraft,author`, the +only step that uses the network; it fails with a plain message when `gh` is +missing or not signed in, and warns when the file is not gitignored. + ### `proof state` `state` shows where one Effort stands, in one read of at most about 4,000 diff --git a/packages/flatbread/bin/flatbread.js b/packages/flatbread/bin/flatbread.js index eba4a855..4379664a 100755 --- a/packages/flatbread/bin/flatbread.js +++ b/packages/flatbread/bin/flatbread.js @@ -26,7 +26,9 @@ function loadCli() { } // Reads `flatbread proof serve` answers. Keep in step with SERVED_READS in -// src/cli/serve.ts; anything else, writes included, runs locally. +// src/cli/serve.ts; anything else, writes included, runs locally. Options +// are forwarded as given, so `find --path` and `find --refs` are served too. +// `proof branches` runs locally: `--refresh-prs` calls gh and writes a file. const SERVED_READS = new Set([ 'list', 'records', diff --git a/packages/flatbread/src/cli/proof-git.test.ts b/packages/flatbread/src/cli/proof-git.test.ts new file mode 100644 index 00000000..9cf5ba03 --- /dev/null +++ b/packages/flatbread/src/cli/proof-git.test.ts @@ -0,0 +1,586 @@ +import test from 'ava'; +import { execFile, spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { + lstat, + mkdir, + mkdtemp, + rm, + symlink, + unlink, + writeFile, +} from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { handleEffortFind, handleEffortWrite } from './proof.js'; +import { serveProofReads } from './serve.js'; + +// `proof find --path` and `--refs` on small fixture repositories made with +// `git init`. Each test builds its own repository in a temp folder. + +const repositoryNodeModules = fileURLToPath( + new URL('../../../../node_modules/', import.meta.url) +); +const GIT_ENV = { + GIT_AUTHOR_NAME: 'Fixture', + GIT_AUTHOR_EMAIL: 'fixture@example.com', + GIT_COMMITTER_NAME: 'Fixture', + GIT_COMMITTER_EMAIL: 'fixture@example.com', + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', +}; + +type Context = { teardown(callback: () => void | Promise): void }; + +async function createProject(t: Context, withGit = true): Promise { + const cwd = await mkdtemp(join(tmpdir(), 'flatbread-proof-git-')); + const nodeModules = join(cwd, 'node_modules'); + await symlink(repositoryNodeModules, nodeModules, 'junction'); + t.teardown(async () => { + if ((await lstat(nodeModules).catch(() => null))?.isSymbolicLink()) + await unlink(nodeModules); + await rm(cwd, { recursive: true, force: true }); + }); + await writeFile( + join(cwd, 'flatbread.config.js'), + `import { source } from '@flatbread/source-filesystem'; +import { transformer } from '@flatbread/transformer-markdown'; +import { proofContent } from '@flatbread/proof'; +export default { + source: source(), + transformer: transformer(), + content: proofContent('.flatbread-proof'), +};` + ); + await writeFile( + join(cwd, '.gitignore'), + 'node_modules\n**/.flatbread-proof/.journal/\n.flatbread/\n' + ); + if (withGit) await git(cwd, 'init', '-q', '-b', 'main'); + return cwd; +} + +function git(cwd: string, ...args: string[]): Promise { + return new Promise((resolve, reject) => + execFile( + 'git', + ['-c', 'commit.gpgsign=false', ...args], + { cwd, env: { ...process.env, ...GIT_ENV } }, + (error, stdout, stderr) => + error + ? reject(new Error(`${error.message}\n${stderr}`)) + : resolve(stdout) + ) + ); +} + +async function commit(cwd: string, message: string): Promise { + await git(cwd, 'add', '-A'); + await git(cwd, 'commit', '-q', '-m', message); +} + +function writer(cwd: string) { + return async (input: Record): Promise => + (await handleEffortWrite(JSON.stringify(input), { cwd })).artifacts[0].id; +} + +function runCli( + cwd: string, + env: Record, + ...args: string[] +): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [ + '--no-deprecation', + fileURLToPath(new URL('../../bin/flatbread.js', import.meta.url)), + ...args, + ], + { cwd, env: { ...process.env, FLATBREAD_PROOF_SERVER: '', ...env } } + ); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => (stdout += chunk)); + child.stderr.on('data', (chunk) => (stderr += chunk)); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + }); +} + +test.serial( + 'find --path ties records to code by code Citation, commit trailer, and body text', + async (t) => { + t.timeout(180_000); + const cwd = await createProject(t); + const write = writer(cwd); + const effort = await write({ + type: 'CreateEffort', + title: 'Fuse', + body: '', + }); + await mkdir(join(cwd, 'src/net'), { recursive: true }); + await writeFile(join(cwd, 'src/fuse.ts'), 'export function budget() {}\n'); + await writeFile(join(cwd, 'src/net/peer.ts'), 'export {};\n'); + await commit(cwd, 'Add the code'); + const sha = (await git(cwd, 'rev-parse', 'HEAD')).trim(); + const citation = await write({ + type: 'WriteCitation', + effort, + title: 'Fuse budget code', + body: `src/fuse.ts#budget@${sha}`, + role: 'code', + }); + const decision = await write({ + type: 'WriteDecision', + effort, + title: 'Cap the fuse budget at 4 ms', + body: 'The budget stops the frame from running long.', + cites: [citation], + }); + const trailer = await write({ + type: 'WriteFinding', + effort, + title: 'The fuse overran on Skiff', + body: 'Measured 6 ms on the slow device.', + kind: 'measurement', + }); + const text = await write({ + type: 'WriteFinding', + effort, + title: 'Where the cap lives', + body: 'The cap is set in src/fuse.ts, next to the frame clock.', + kind: 'observation', + }); + await write({ + type: 'WriteFinding', + effort, + title: 'Peer handshake', + body: 'The handshake is in src/net/peer.ts.', + kind: 'observation', + }); + await commit(cwd, 'Record the fuse work'); + await writeFile( + join(cwd, 'src/fuse.ts'), + 'export function budget() { return 4; }\n' + ); + await commit(cwd, `Cap the fuse\n\nProof: ${trailer}`); + + const byPath = await handleEffortFind('', { cwd, path: 'src/fuse.ts' }); + t.deepEqual( + byPath.find.hits.map((hit) => hit.id), + [decision, trailer, text] + ); + t.deepEqual(byPath.find.hits[0].via, [ + `cites ${citation}: src/fuse.ts#budget@${sha.slice(0, 7)}`, + ]); + t.regex( + byPath.find.hits[1].via?.[0] ?? '', + /^commit [0-9a-f]{7} "Cap the fuse"$/ + ); + t.deepEqual(byPath.find.hits[2].via, ['body names src/fuse.ts']); + t.regex( + byPath.cards, + /^find --path src\/fuse\.ts in all Efforts \(3 governing, 3 shown\)\n/ + ); + t.notRegex(byPath.cards, /score|^matched:/m); + + // Words rank the governed set by BM25. + const worded = await handleEffortFind('frame clock', { + cwd, + path: 'src/fuse.ts', + }); + t.is(worded.find.hits[0].id, text); + // A folder matches the files under it; a symbol matches #symbol anchors. + const folder = await handleEffortFind('', { cwd, path: 'src' }); + t.is(folder.find.hits.length, 4); + const symbol = await handleEffortFind('', { cwd, path: 'budget' }); + t.deepEqual( + symbol.find.hits.map((hit) => hit.id), + [decision] + ); + t.deepEqual( + ( + await handleEffortFind('', { cwd, path: 'src/fuse.ts#budget' }) + ).find.hits.map((hit) => hit.id), + [decision, trailer] + ); + + // Served and local print the same, and plain find has no new lines. + const socket = join(cwd, '.flatbread/proof/serve.sock'); + const server = await serveProofReads(socket); + t.teardown(() => new Promise((done) => server.close(() => done()))); + for (const args of [ + ['proof', 'find', '--path', 'src/fuse.ts'], + ['proof', 'find', 'budget', '--path', 'src', '--effort', effort], + ]) { + const local = await runCli(cwd, {}, ...args); + t.is(local.code, 0, local.stderr); + const remote = await runCli( + cwd, + { FLATBREAD_PROOF_SERVER: socket }, + ...args + ); + t.is(remote.code, 0, remote.stderr); + t.deepEqual(JSON.parse(remote.stdout), JSON.parse(local.stdout)); + } + const plain = await handleEffortFind('fuse budget', { cwd }); + t.notRegex(plain.cards, /via:|branch:|CONFLICTS/); + t.false('path' in plain.find); + const none = await runCli(cwd, {}, 'proof', 'find'); + t.is(none.code, 1); + t.regex(JSON.parse(none.stderr).error.message, /at least one word/); + } +); + +test.serial('find --path outside git skips trailers quietly', async (t) => { + t.timeout(120_000); + const cwd = await createProject(t, false); + const write = writer(cwd); + const effort = await write({ type: 'CreateEffort', title: 'Fuse', body: '' }); + const text = await write({ + type: 'WriteFinding', + effort, + title: 'Where the cap lives', + body: 'The cap is set in src/fuse.ts.', + kind: 'observation', + }); + const found = await handleEffortFind('', { cwd, path: 'src/fuse.ts' }); + t.deepEqual( + found.find.hits.map((hit) => hit.id), + [text] + ); + await t.throwsAsync(handleEffortFind('', { cwd, refs: 'all' }), { + message: /inside a git repository/, + }); +}); + +test.serial( + 'find --path on a path only a branch has: a plain hint, and --refs reads the branch tree and trailers', + async (t) => { + t.timeout(180_000); + const cwd = await createProject(t); + const write = writer(cwd); + const effort = await write({ + type: 'CreateEffort', + title: 'Net', + body: '', + }); + const finding = await write({ + type: 'WriteFinding', + effort, + title: 'Peers drop on hotel Wi-Fi', + body: 'Packets drop.', + kind: 'measurement', + }); + await commit(cwd, 'Base graph'); + // A branch that only adds code, tied to the main record by a trailer. + await git(cwd, 'checkout', '-q', '-b', 'feat/net', 'main'); + await mkdir(join(cwd, 'netcode'), { recursive: true }); + await writeFile(join(cwd, 'netcode/peer.rs'), 'fn open() {}\n'); + await commit(cwd, `Add the peer code\n\nProof: ${finding}`); + await git(cwd, 'checkout', '-q', 'main'); + + // Without --refs: empty cards that say why and name the branch. + const hinted = await handleEffortFind('', { cwd, path: 'netcode/peer.rs' }); + t.deepEqual(hinted.find.hits, []); + const note = + 'note: netcode/peer.rs is not in the working tree; feat/net has it. --refs all overlays branches, with their records and commit trailers.'; + t.is(hinted.find.note, note); + t.true(hinted.cards.split('\n').includes(note), hinted.cards); + t.true(hinted.summary.endsWith(`; ${note}`), hinted.summary); + // A bare word a branch holds as a folder gets the hint too. + t.regex( + (await handleEffortFind('', { cwd, path: 'netcode' })).cards, + /\nnote: netcode is not in the working tree; feat\/net has it as a path\./ + ); + const nowhere = await handleEffortFind('', { cwd, path: 'src/gone.ts' }); + t.is( + nowhere.find.note, + 'note: src/gone.ts is not in the working tree or on any branch. --refs all overlays branches, with their records and commit trailers.' + ); + + // With --refs: the branch tree says netcode is a folder, and the + // trailer on the branch governs it, though the branch changes no record. + for (const refs of ['all', ['feat/net']] as const) { + const found = await handleEffortFind('', { + cwd, + path: 'netcode', + refs: refs === 'all' ? 'all' : [...refs], + }); + t.is(found.find.path, 'netcode/'); + t.deepEqual( + found.find.hits.map((hit) => hit.id), + [finding] + ); + t.regex(found.find.hits[0].via?.[0] ?? '', /^commit [0-9a-f]{7} "Add/); + t.is(found.find.note, undefined); + } + const missing = await handleEffortFind('', { + cwd, + path: 'src/gone.ts', + refs: ['feat/net'], + }); + t.is( + missing.find.note, + 'note: src/gone.ts is not in the working tree or on feat/net; --refs all overlays every branch.' + ); + } +); + +test.serial( + 'find --refs overlays branches without a checkout, tags each card, and lists conflicts', + async (t) => { + t.timeout(240_000); + const cwd = await createProject(t); + const write = writer(cwd); + const effort = await write({ + type: 'CreateEffort', + title: 'Net', + body: '', + }); + const question = await write({ + type: 'WriteIssue', + effort, + title: 'Which transport carries co-op?', + body: 'We need one transport for co-op sessions.', + kind: 'question', + }); + const proposed = await write({ + type: 'WriteDecision', + effort, + title: 'Use TCP for co-op', + body: 'TCP is simple.', + derives_from: [question], + }); + await commit(cwd, 'Base graph'); + const before = await handleEffortFind('transport co-op', { cwd }); + + const propose = async (branch: string, title: string) => { + await git(cwd, 'checkout', '-q', '-b', branch, 'main'); + const id = await write({ + type: 'WriteDecision', + effort, + title, + body: `${title} for the co-op transport.`, + derives_from: [question], + }); + await write({ type: 'AcceptDecision', decisionId: id }); + await commit(cwd, title); + return id; + }; + const quic = await propose('feat/quic', 'Use QUIC'); + const udp = await propose('feat/udp', 'Use raw UDP'); + await git(cwd, 'checkout', '-q', 'main'); + // A branch merely behind main adds nothing. + await git(cwd, 'branch', 'stale', 'main'); + await mkdir(join(cwd, '.flatbread/proof'), { recursive: true }); + await writeFile( + join(cwd, '.flatbread/proof/prs.json'), + JSON.stringify([ + { number: 3, headRefName: 'feat/quic', isDraft: true, author: 'ana' }, + ]) + ); + + // Plain find on the working tree is unchanged by the branches. + const after = await handleEffortFind('transport co-op', { cwd }); + t.is(after.cards, before.cards); + + const overlay = await handleEffortFind('', { cwd, refs: 'all' }); + t.deepEqual(overlay.find.refs?.map((ref) => ref.name).sort(), [ + 'feat/quic', + 'feat/udp', + ]); + const branch = Object.fromEntries( + overlay.find.hits.map((hit) => [hit.id, hit.branch]) + ); + t.is(branch[quic], 'only on feat/quic (draft #3 by ana)'); + t.is(branch[udp], 'only on feat/udp'); + // Refs list newest commit first, and the two commits may share a second. + t.deepEqual(branch[proposed]?.split('; ').sort(), [ + 'differs on feat/quic (draft #3 by ana): proposed vs rejected', + 'differs on feat/udp: proposed vs rejected', + ]); + t.deepEqual(overlay.find.conflicts, [ + `two accepted Decisions on question ${question}: ${[quic, udp] + .sort() + .map((id) => `${id} (${id === quic ? 'feat/quic' : 'feat/udp'})`) + .join(', ')}`, + ]); + t.regex(overlay.cards, /\nCONFLICTS \(1\)\n- two accepted Decisions/); + t.regex(overlay.cards, /\nrefs: (?:feat\/quic|feat\/udp)/); + + // Named refs, words, and --effort compose; the base record shows `main`. + const named = await handleEffortFind('transport', { + cwd, + refs: ['feat/udp'], + effort, + }); + t.deepEqual( + named.find.refs?.map((ref) => ref.name), + ['feat/udp'] + ); + t.is(named.find.hits.find((hit) => hit.id === question)?.branch, 'main'); + t.deepEqual(named.find.conflicts, []); + t.regex(named.cards, /\nCONFLICTS: none$/); + await t.throwsAsync(handleEffortFind('x', { cwd, refs: ['nope'] }), { + message: /Unknown git ref: nope/, + }); + + // --refs composes with --path: a code Citation only on a branch counts. + await git(cwd, 'checkout', '-q', 'feat/udp'); + const citation = await write({ + type: 'WriteCitation', + effort, + title: 'UDP socket code', + body: 'src/net/udp.ts#open', + role: 'code', + }); + const cited = await write({ + type: 'WriteFinding', + effort, + title: 'UDP drops on hotel Wi-Fi', + body: 'Packets drop.', + kind: 'measurement', + cites: [citation], + }); + await commit(cwd, 'Cite the UDP code'); + await git(cwd, 'checkout', '-q', 'main'); + const both = await handleEffortFind('', { + cwd, + path: 'src/net/udp.ts', + refs: 'all', + }); + t.deepEqual( + both.find.hits.map((hit) => [hit.id, hit.branch]), + [[cited, 'only on feat/udp']] + ); + t.deepEqual( + (await handleEffortFind('', { cwd, path: 'src/net/udp.ts' })).find.hits, + [] + ); + + // Served and local print the same. + const socket = join(cwd, '.flatbread/proof/serve.sock'); + const server = await serveProofReads(socket); + t.teardown(() => new Promise((done) => server.close(() => done()))); + for (const args of [ + ['proof', 'find', '--refs', 'all'], + ['proof', 'find', 'transport', '--refs', 'feat/quic,feat/udp', '--all'], + ['proof', 'find', '--path', 'src/net', '--refs', 'all'], + ]) { + const local = await runCli(cwd, {}, ...args); + t.is(local.code, 0, local.stderr); + const remote = await runCli( + cwd, + { FLATBREAD_PROOF_SERVER: socket }, + ...args + ); + t.is(remote.code, 0, remote.stderr); + t.deepEqual(JSON.parse(remote.stdout), JSON.parse(local.stdout)); + } + const branches = await runCli(cwd, {}, 'proof', 'branches'); + t.is(branches.code, 0, branches.stderr); + t.deepEqual( + JSON.parse(branches.stdout) + .refs.map((ref: { name: string }) => ref.name) + .sort(), + ['feat/quic', 'feat/udp'] + ); + } +); + +test.serial( + 'find --refs: ResolveIssue rivals conflict; an older ref and a squash-merged branch do not', + async (t) => { + t.timeout(240_000); + const cwd = await createProject(t); + const write = writer(cwd); + const effort = await write({ + type: 'CreateEffort', + title: 'Net', + body: '', + }); + const question = await write({ + type: 'WriteIssue', + effort, + title: 'How many frames of input delay?', + body: 'Pick the delay.', + kind: 'question', + }); + await commit(cwd, 'Base graph'); + // Each branch answers through ResolveIssue, with no derives_from. + const answer = async (branch: string, frames: number) => { + await git(cwd, 'checkout', '-q', '-b', branch, 'main'); + const id = await write({ + type: 'WriteDecision', + effort, + title: `Use ${frames} frames`, + body: `${frames} frames of delay.`, + }); + await write({ type: 'AcceptDecision', decisionId: id }); + await write({ + type: 'ResolveIssue', + issueId: question, + resolution: 'resolved', + resolvedBy: [id], + }); + await commit(cwd, `Use ${frames} frames`); + return id; + }; + const two = await answer('feat/d2', 2); + const four = await answer('feat/d4', 4); + // origin/feat/d4 stands one commit behind feat/d4: it holds a Decision + // that feat/d4 then accepts. + const later = await write({ + type: 'WriteDecision', + effort, + title: 'Use a jitter buffer', + body: 'Buffer.', + }); + await commit(cwd, 'Propose a jitter buffer'); + await git(cwd, 'remote', 'add', 'origin', 'https://example.invalid/r.git'); + await git(cwd, 'update-ref', 'refs/remotes/origin/feat/d4', 'HEAD'); + await write({ type: 'AcceptDecision', decisionId: later }); + await commit(cwd, 'Accept the jitter buffer'); + await git(cwd, 'checkout', '-q', 'main'); + + const overlay = await handleEffortFind('', { cwd, refs: 'all' }); + t.deepEqual(overlay.find.conflicts, [ + `two accepted Decisions on question ${question}: ${[two, four] + .sort() + .map((id) => `${id} (${id === two ? 'feat/d2' : 'feat/d4'})`) + .join(', ')}`, + ]); + const branch = Object.fromEntries( + overlay.find.hits.map((hit) => [hit.id, hit.branch]) + ); + t.is(branch[later], 'only on feat/d4'); + t.regex(overlay.cards, new RegExp(`in: resolves ${question}`)); + // origin/feat/d4, which feat/d4 contains, is left out. + t.deepEqual(overlay.find.refs?.map((ref) => ref.name).sort(), [ + 'feat/d2', + 'feat/d4', + ]); + + // Squash-merge feat/d2, then supersede its Decision on main: the + // branch left behind is merged, not a rival. + await git(cwd, 'merge', '-q', '--squash', 'feat/d2'); + await commit(cwd, 'Squash feat/d2'); + const three = await write({ + type: 'WriteDecision', + effort, + title: 'Use 3 frames', + body: '3 frames of delay.', + }); + await write({ type: 'Supersede', supersederId: three, targetId: two }); + await commit(cwd, 'Use 3 frames'); + const merged = await handleEffortFind('', { cwd, refs: 'all' }); + t.false(merged.find.refs?.some((ref) => ref.name === 'feat/d2')); + // What is left is real: main resolved the question with the squashed + // Decision, and feat/d4 resolves it with its own. + t.deepEqual(merged.find.conflicts, [ + `${question} resolved differently: by ${two} on base; by ${four} on feat/d4`, + ]); + } +); diff --git a/packages/flatbread/src/cli/proof.ts b/packages/flatbread/src/cli/proof.ts index 94b1506e..6c3ee90f 100644 --- a/packages/flatbread/src/cli/proof.ts +++ b/packages/flatbread/src/cli/proof.ts @@ -25,6 +25,12 @@ import { listEfforts, type RetractedMode, } from '../proof/read.js'; +import { + changeCounts, + readRefSides, + refreshPullRequests, + refsLine, +} from '../proof/refs.js'; import type { PrimitiveKind, ReadRelation } from '@flatbread/proof'; import { handleEffortInstallSkill } from './installSkill.js'; @@ -48,6 +54,9 @@ export interface EffortCliOptions { effort?: string; all?: boolean; asOf?: string; + path?: string; + refs?: string[] | 'all'; + refreshPrs?: boolean; verify?: boolean; dryRun?: boolean; } @@ -94,6 +103,17 @@ export function mapEffortCliOptions( typeof options['as-of'] === 'number' ? String(options['as-of']) : undefined, + path: typeof options.path === 'string' ? options.path : undefined, + refs: + options.refs === 'all' + ? 'all' + : typeof options.refs === 'string' + ? split(options.refs) ?? [] + : undefined, + refreshPrs: + options['refresh-prs'] === true || options['refresh-prs'] === 'true' + ? true + : undefined, verify: options.verify === true ? true : undefined, dryRun: options['dry-run'] === true || options.dryRun === true @@ -304,9 +324,57 @@ export async function handleEffortFind( asOf: options.asOf, retracted: options.retracted, limit: options.limit, + path: options.path, + refs: options.refs, }); } +/** + * `proof branches`: the refs `proof find --refs all` overlays, each with how + * many Proof records it changes and its pull request, if + * `.flatbread/proof/prs.json` names one. `--refresh-prs` first rewrites that + * file from `gh pr list`, the only step that uses the network. + */ +export async function handleProofBranches(options: EffortCliOptions = {}) { + const cwd = options.cwd ?? process.cwd(); + const rootDir = await rootFor(cwd); + const cacheRoot = resolve(cwd, '.flatbread/proof'); + const refreshed = options.refreshPrs + ? await refreshPullRequests(rootDir, cacheRoot) + : undefined; + const refSet = await readRefSides(rootDir, cacheRoot, 'all'); + return { + ...(refreshed + ? { + prs: { + path: refreshed.path, + count: refreshed.count, + ...(refreshed.ignored + ? {} + : { + warning: `${refreshed.path} is not gitignored; add .flatbread/proof/prs.json to .gitignore`, + }), + }, + } + : {}), + summary: refsLine(refSet), + refs: refSet.sides.map((side) => ({ + name: side.name, + sha: side.sha, + changed: changeCounts(side).records, + ...(side.pr + ? { + pr: { + number: side.pr.number, + draft: side.pr.isDraft, + author: side.pr.author, + }, + } + : {}), + })), + }; +} + /** `proof state`: where one Effort stands, from frontmatter and links. */ export async function handleEffortState( effortId: string, @@ -612,10 +680,18 @@ export function registerProofCommands(prog: any): void { ); consistencyOptions( prog.command( - 'proof find ', + 'proof find [words]', 'Find records by what they say, ranked; one card per supersession chain' ) ) + .option( + '--path ', + 'Records that govern this file, folder, symbol, or file#symbol; words optional' + ) + .option( + '--refs ', + 'Overlay records from these comma-separated git refs, or all changed branches, without a checkout' + ) .option('--effort ', 'Search one Effort') .option( '--all', @@ -649,6 +725,19 @@ export function registerProofCommands(prog: any): void { .action(async (effortId: string, options: Record) => printResult(handleEffortState(effortId, mapEffortCliOptions(options))) ); + prog + .command( + 'proof branches', + 'List the branches proof find --refs all overlays, with their pull requests' + ) + .option( + '--refresh-prs', + 'First rewrite .flatbread/proof/prs.json from gh pr list (uses the network)', + false + ) + .action(async (options: Record) => + printResult(handleProofBranches(mapEffortCliOptions(options))) + ); paginationOptions( consistencyOptions( prog.command('proof list', 'List Efforts by lifecycle status') diff --git a/packages/flatbread/src/cli/serve.ts b/packages/flatbread/src/cli/serve.ts index 17c8b1ce..fc24694c 100644 --- a/packages/flatbread/src/cli/serve.ts +++ b/packages/flatbread/src/cli/serve.ts @@ -37,8 +37,10 @@ export const SERVED_READS: Readonly> = { /** Options that take no value. */ const BOOLEAN_FLAGS = new Set(['all']); /** - * Reads whose positionals are words of one query. They take one or more, and - * the words are joined with spaces, as the local CLI joins them. + * Reads whose positionals are words of one query. They take any number, and + * the words are joined with spaces, as the local CLI joins them. `find` with + * no words is valid with `--path` or `--refs`; the handler refuses it + * otherwise, as the local CLI does. */ const JOINED_POSITIONALS = new Set(['find']); @@ -101,7 +103,7 @@ export function parseReadArgs(argv: readonly string[]): { else if (i + 1 < rest.length) options[arg.slice(2)] = rest[++i]; else invalid(`Option ${arg} needs a value`); } - if (JOINED_POSITIONALS.has(command) && positional.length) + if (JOINED_POSITIONALS.has(command)) return { command, positional: [positional.join(' ')], options }; if (positional.length !== SERVED_READS[command]) invalid( diff --git a/packages/flatbread/src/proof/anchors.test.ts b/packages/flatbread/src/proof/anchors.test.ts new file mode 100644 index 00000000..618c2dfa --- /dev/null +++ b/packages/flatbread/src/proof/anchors.test.ts @@ -0,0 +1,294 @@ +import test from 'ava'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { PrimitiveKind, ReadRecord } from '@flatbread/proof'; +import { + anchorMatches, + codeAnchors, + governingRecords, + namesTarget, + parseCodeTarget, + type CodeTarget, +} from './anchors.js'; +import { buildFindIndex, findRecords, renderFindCards } from './find.js'; + +const KINDS: Record = { + eff: 'effort', + iss: 'issue', + fnd: 'finding', + dec: 'decision', + cit: 'citation', +}; + +function rec( + id: string, + fields: { + title?: string; + body?: string; + created_at?: string; + role?: string; + state?: string; + retracted?: boolean; + relations?: ReadRecord['relations']; + } = {} +): ReadRecord { + const kind = KINDS[id.split('-', 1)[0]]; + const { body, relations, ...frontmatter } = fields; + return { + id, + kind, + path: `${id}.md`, + frontmatter: { + title: id, + created_at: '2026-01-01T00:00:00.000Z', + ...(kind === 'effort' ? {} : { effort: 'eff-a' }), + ...frontmatter, + }, + body_excerpt: body ?? '', + relations: relations ?? {}, + }; +} + +const file = (path: string, symbol?: string): CodeTarget => ({ + path, + dir: false, + file: true, + ...(symbol ? { symbol } : {}), + label: path, +}); +const dir = (path: string): CodeTarget => ({ + path, + dir: true, + file: false, + label: `${path}/`, +}); +const symbol = (name: string): CodeTarget => ({ + dir: false, + file: false, + symbol: name, + label: name, +}); +const all = () => true; + +test('a --path value reads as a file, a folder, a symbol, or both', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'anchors-')); + t.teardown(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, 'pkg/src'), { recursive: true }); + await writeFile(join(root, 'pkg/src/run.ts'), ''); + const cwd = join(root, 'pkg'); + t.like(await parseCodeTarget('src/run.ts', cwd, root), { + path: 'pkg/src/run.ts', + dir: false, + file: true, + }); + t.like(await parseCodeTarget('./src', cwd, root), { + path: 'pkg/src', + dir: true, + file: false, + label: 'pkg/src/', + }); + t.like(await parseCodeTarget('src/run.ts#start', cwd, root), { + path: 'pkg/src/run.ts', + symbol: 'start', + }); + t.like(await parseCodeTarget('startFrame()', cwd, root), { + path: undefined, + symbol: 'startFrame', + }); + t.like(await parseCodeTarget('Planner.plan', cwd, root), { + symbol: 'Planner.plan', + }); + // Not on disk: taken from the repository root, as a file and a folder. + t.like(await parseCodeTarget('lib/gone.ts', cwd, root), { + path: 'lib/gone.ts', + dir: true, + file: true, + }); + // The repository root itself names everything, so it is refused. + for (const value of ['.', './', root]) + await t.throwsAsync(parseCodeTarget(value, root, root), { + message: /names the whole repository/, + }); +}); + +test('a --path value missing from the working tree resolves against ref trees', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'anchors-')); + t.teardown(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, 'pkg/src'), { recursive: true }); + await writeFile(join(root, 'pkg/src/run.ts'), ''); + const cwd = join(root, 'pkg'); + // On disk, from the cwd or from the repository root: not absent. + t.is((await parseCodeTarget('src/run.ts', cwd, root)).absent, undefined); + t.like(await parseCodeTarget('pkg/src', cwd, root), { + path: 'pkg/src', + dir: true, + file: false, + absent: undefined, + }); + t.like(await parseCodeTarget('lib/gone.ts', cwd, root), { absent: true }); + const trees: Record = { + netcode: 'dir', + 'pkg/feature/wire.ts': 'file', + }; + const inRefs = async (path: string) => trees[path]; + // A bare word a ref holds as a folder is that folder, not a symbol. + t.like(await parseCodeTarget('netcode', cwd, root, inRefs), { + path: 'netcode', + dir: true, + file: false, + absent: true, + label: 'netcode/', + }); + t.like(await parseCodeTarget('netcode', cwd, root), { + path: undefined, + symbol: 'netcode', + }); + // A path from the cwd that only a ref has. + t.like(await parseCodeTarget('feature/wire.ts', cwd, root, inRefs), { + path: 'pkg/feature/wire.ts', + dir: false, + file: true, + absent: true, + }); + // On no ref either: as before, a file and a folder from the root. + t.like(await parseCodeTarget('lib/gone.ts', cwd, root, inRefs), { + path: 'lib/gone.ts', + dir: true, + file: true, + absent: true, + }); +}); + +test('code anchors parse path, symbol, and sha, and skip URLs and prose', (t) => { + t.deepEqual( + codeAnchors( + 'Pins `src/fuse.ts#budget@1a2b3c4d` and docs/fuse.md. See ' + + 'https://github.com/o/r/blob/main/src/other.ts, e.g. the plan.' + ), + [ + { path: 'src/fuse.ts', symbol: 'budget', sha: '1a2b3c4d' }, + { path: 'docs/fuse.md' }, + ] + ); +}); + +test('anchors match a file, files under a folder, and a symbol', (t) => { + const anchor = { path: 'src/fuse.ts', symbol: 'budget' }; + t.true(anchorMatches(anchor, file('src/fuse.ts'))); + t.true(anchorMatches(anchor, file('src/fuse.ts', 'budget'))); + t.false(anchorMatches(anchor, file('src/fuse.ts', 'other'))); + // A file anchor with no symbol covers every symbol in it. + t.true(anchorMatches({ path: 'src/fuse.ts' }, file('src/fuse.ts', 'x'))); + t.true(anchorMatches(anchor, dir('src'))); + t.false(anchorMatches(anchor, dir('sr'))); + t.true(anchorMatches(anchor, symbol('budget'))); + t.false(anchorMatches({ path: 'src/fuse.ts' }, symbol('budget'))); +}); + +test('a body names a path only as a whole token, and a symbol as name( or #name', (t) => { + t.true(namesTarget('We changed src/fuse.ts.', file('src/fuse.ts'))); + t.false(namesTarget('We changed src/fuse.tsx', file('src/fuse.ts'))); + t.false(namesTarget('We changed lib/src/fuse.ts', file('src/fuse.ts'))); + t.true(namesTarget('see src/net/peer.ts', dir('src/net'))); + t.false(namesTarget('see src/network.ts', dir('src/net'))); + t.true(namesTarget('calls budget() each frame', symbol('budget'))); + t.true(namesTarget('see src/fuse.ts#budget', symbol('budget'))); + t.false(namesTarget('the budget is 4 ms', symbol('budget'))); + // A method call names the method. + t.true( + namesTarget('Calling this.budget() twice is cheap.', symbol('budget')) + ); + t.false(namesTarget('calls overbudget() once', symbol('budget'))); + t.true( + namesTarget('src/fuse.ts calls budget()', file('src/fuse.ts', 'budget')) + ); + t.false(namesTarget('src/fuse.ts only', file('src/fuse.ts', 'budget'))); +}); + +test('a code Citation governs its citers and causes; trailers and text add weaker ties', (t) => { + const index = buildFindIndex([ + rec('eff-a'), + rec('cit-code', { role: 'code', body: 'src/fuse.ts#budget@1a2b3c4' }), + rec('cit-url', { body: 'https://example.com/src/fuse.ts' }), + rec('cit-orphan', { role: 'code', body: 'src/fuse.ts' }), + rec('dec-cites', { + state: 'accepted', + relations: { cites: ['cit-code'] }, + }), + rec('fnd-trailer'), + rec('fnd-text', { body: 'The cap lives in src/fuse.ts.' }), + rec('fnd-other', { body: 'Nothing about code.' }), + ]); + const governed = governingRecords( + index, + file('src/fuse.ts'), + [{ sha: 'abcdef1234567', subject: 'Cap the fuse', ids: ['fnd-trailer'] }], + all + ); + t.deepEqual([...governed.keys()].sort(), [ + 'cit-orphan', + 'dec-cites', + 'fnd-text', + 'fnd-trailer', + ]); + t.deepEqual(governed.get('dec-cites'), [ + { + strength: 3, + record: 'dec-cites', + text: 'cites cit-code: src/fuse.ts#budget@1a2b3c4', + }, + ]); + t.is(governed.get('fnd-trailer')?.[0].text, 'commit abcdef1 "Cap the fuse"'); + t.is(governed.get('fnd-text')?.[0].strength, 1); +}); + +test('governed cards resolve to the chain head and rank by tie, then recency', (t) => { + const index = buildFindIndex([ + rec('eff-a'), + rec('cit-code', { role: 'code', body: 'src/fuse.ts' }), + rec('dec-old', { + state: 'superseded', + relations: { cites: ['cit-code'], superseded_by: ['dec-new'] }, + }), + rec('dec-new', { + state: 'accepted', + created_at: '2026-02-01T00:00:00.000Z', + relations: { supersedes: ['dec-old'] }, + }), + rec('fnd-text-old', { body: 'see src/fuse.ts' }), + rec('fnd-text-new', { + body: 'see src/fuse.ts again', + created_at: '2026-03-01T00:00:00.000Z', + }), + rec('fnd-retracted', { body: 'src/fuse.ts', retracted: true }), + ]); + const governed = governingRecords(index, file('src/fuse.ts'), [], all); + const result = findRecords(index, '', { governed }); + t.deepEqual( + result.hits.map((hit) => hit.record.id), + ['dec-new', 'fnd-text-new', 'fnd-text-old'] + ); + t.true(result.unscored); + const cards = renderFindCards(index, { + ...result, + header: 'find --path src/fuse.ts', + }); + t.regex( + cards, + /1\. dec-new \| decision \| accepted \(head\) \| 2026-02-01\n/ + ); + t.regex(cards, /via: cites cit-code: src\/fuse\.ts \(on dec-old\)/); + t.notRegex(cards, /score/); + t.notRegex(cards, /^matched:/m); + // Words rank the governed set by BM25, and only it. + const worded = findRecords(index, 'again', { governed }); + t.is(worded.hits[0].record.id, 'fnd-text-new'); + t.is(worded.hits.length, 3); + // A retracted record shows only when asked for. + t.true( + findRecords(index, '', { governed, retracted: 'include' }).hits.some( + (hit) => hit.record.id === 'fnd-retracted' + ) + ); +}); diff --git a/packages/flatbread/src/proof/anchors.ts b/packages/flatbread/src/proof/anchors.ts new file mode 100644 index 00000000..c8553842 --- /dev/null +++ b/packages/flatbread/src/proof/anchors.ts @@ -0,0 +1,368 @@ +import { execFile } from 'node:child_process'; +import { stat } from 'node:fs/promises'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; +import { ProofReadValidationError, type ReadRecord } from '@flatbread/proof'; +import type { FindIndex, FindVia } from './find.js'; + +/** + * `proof find --path`: the records that govern a piece of code. Three + * sources tie a record to code, strongest first: + * + * - a Citation with `role: code` whose body names the code as + * `#@` or ``. The records that cite it govern + * the code, and so do the records it derives from. A code Citation that + * nothing cites stands for itself. + * - a git commit that touches the path and carries `Proof: ` trailers. + * - a record body that names the path, or the symbol, exactly. + * + * Everything here but the git read is a pure function of its inputs. + */ + +export const STRENGTH = { citation: 3, trailer: 2, text: 1 } as const; + +/** What `--path` names: a file, a folder, a symbol, or a file and a symbol. */ +export interface CodeTarget { + /** Repository-relative, with `/` separators and no leading `./`. */ + readonly path?: string; + /** Whether files under `path` count. */ + readonly dir: boolean; + /** Whether `path` itself counts: false for a folder that exists. */ + readonly file: boolean; + readonly symbol?: string; + /** As the card header prints it. */ + readonly label: string; + /** `path` is not in the working tree. */ + readonly absent?: true; +} + +/** One code reference in a code Citation's body. */ +export interface CodeAnchor { + readonly path: string; + readonly symbol?: string; + readonly sha?: string; +} + +const CODE_EXTENSION = + /\.(?:ts|tsx|js|jsx|mjs|cjs|rs|go|py|rb|java|kt|swift|c|h|cc|cpp|hpp|cs|json|ya?ml|toml|md|css|scss|html|sql|sh|glsl|hlsl|wgsl|vue|svelte)$/i; +const IDENTIFIER = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/; +/** + * ``, `#`, `@`, or `#@`. + * A path starts after a space or opening punctuation, so the middle of a URL + * never matches. + */ +const ANCHOR = + /(? Promise<'file' | 'dir' | undefined>; + +/** + * Reads a `--path` value. `file#symbol` names both. A value that exists on + * disk relative to `cwd`, or else relative to `repoRoot`, is that file or + * folder, made relative to `repoRoot`. A value on neither is `absent`; with + * `inRefs`, a ref's tree that holds it, read from `cwd` or from + * `repoRoot`, says whether it is a file or a folder. Otherwise a bare + * identifier, or one ending in `()`, names a symbol, and anything else is a + * path already relative to `repoRoot`, matched as a file and as a folder. + */ +export async function parseCodeTarget( + value: string, + cwd: string, + repoRoot: string, + inRefs?: RefTreeLookup +): Promise { + const raw = value.trim(); + const hash = raw.indexOf('#'); + const pathPart = hash >= 0 ? raw.slice(0, hash) : raw; + const symbolPart = + hash >= 0 ? raw.slice(hash + 1).replace(/\(\)$/, '') : undefined; + if (!pathPart && symbolPart) + return { dir: false, file: false, symbol: symbolPart, label: raw }; + const absolute = isAbsolute(pathPart) ? pathPart : resolve(cwd, pathPart); + const fromRoot = relative(repoRoot, absolute).split(sep).join('/'); + const inRepo = !fromRoot.startsWith('..') && !isAbsolute(fromRoot); + const asGiven = clean(pathPart); + const atCwd = await stat(absolute).catch(() => null); + const info = + atCwd ?? + (asGiven && !isAbsolute(pathPart) + ? await stat(resolve(repoRoot, asGiven)).catch(() => null) + : null); + let inRef: { path: string; kind: 'file' | 'dir' } | undefined; + if (!info && inRefs) + for (const candidate of new Set([inRepo ? clean(fromRoot) : '', asGiven])) + if (candidate && !candidate.includes('..')) { + const kind = await inRefs(candidate); + if (kind) { + inRef = { path: candidate, kind }; + break; + } + } + if ( + !info && + !inRef && + hash < 0 && + (raw.endsWith('()') || (!pathLike(raw) && IDENTIFIER.test(raw))) + ) + return { + dir: false, + file: false, + symbol: raw.replace(/\(\)$/, ''), + label: raw, + }; + const path = inRef?.path ?? clean(atCwd && inRepo ? fromRoot : pathPart); + if (!path) + throw new ProofReadValidationError( + 'PROOF_INVALID_ARGUMENT', + `--path ${raw} names the whole repository; give a file, folder, or symbol below its root` + ); + const dir = info ? info.isDirectory() : inRef ? inRef.kind === 'dir' : true; + const file = info + ? !info.isDirectory() + : inRef + ? inRef.kind === 'file' + : !pathPart.endsWith('/'); + return { + path, + dir, + file, + ...(symbolPart ? { symbol: symbolPart } : {}), + label: `${path}${dir && !file ? '/' : ''}${ + symbolPart ? `#${symbolPart}` : '' + }`, + ...(info ? {} : { absent: true as const }), + }; +} + +/** Code references in a code Citation's body, in order, each once. */ +export function codeAnchors(body: string): CodeAnchor[] { + const anchors: CodeAnchor[] = []; + const seen = new Set(); + for (const match of body.matchAll(ANCHOR)) { + const path = clean(match[1]); + if (!pathLike(path) || path.includes('..')) continue; + const key = `${path}#${match[2] ?? ''}@${match[3] ?? ''}`; + if (seen.has(key)) continue; + seen.add(key); + anchors.push({ + path, + ...(match[2] ? { symbol: match[2] } : {}), + ...(match[3] ? { sha: match[3] } : {}), + }); + } + return anchors; +} + +function pathMatches(path: string, target: CodeTarget): boolean { + if (target.path === undefined) return false; + return ( + (target.file && path === target.path) || + (target.dir && path.startsWith(`${target.path}/`)) + ); +} + +/** + * Whether an anchor names the target. A file anchor with no symbol covers + * every symbol in the file; a bare symbol target matches `#symbol` in any + * file. + */ +export function anchorMatches(anchor: CodeAnchor, target: CodeTarget): boolean { + if (target.path === undefined) return anchor.symbol === target.symbol; + return ( + pathMatches(anchor.path, target) && + (!target.symbol || !anchor.symbol || anchor.symbol === target.symbol) + ); +} + +export function anchorText(anchor: CodeAnchor): string { + return `${anchor.path}${anchor.symbol ? `#${anchor.symbol}` : ''}${ + anchor.sha ? `@${anchor.sha.slice(0, 7)}` : '' + }`; +} + +/** + * Whether a body names the target exactly. A path must stand as a whole + * token: `src/a.ts` does not name `src/a.tsx`. A symbol is named as + * `symbol(`, `obj.symbol(`, or `#symbol`. A file and a symbol need both. + */ +export function namesTarget(body: string, target: CodeTarget): boolean { + const symbol = target.symbol + ? new RegExp( + `(?:(? boolean +): Map { + const governed = new Map(); + const add = (record: string, strength: number, text: string) => { + if (!index.records.has(record)) return; + if (!governed.has(record)) governed.set(record, []); + (governed.get(record) as FindVia[]).push({ strength, record, text }); + }; + for (const record of index.records.values()) { + if (record.kind === 'effort') continue; + if (isCodeCitation(record)) { + if (!usable(record)) continue; + const anchors = codeAnchors(record.body_excerpt).filter((anchor) => + anchorMatches(anchor, target) + ); + if (!anchors.length) continue; + const named = anchors.slice(0, 2).map(anchorText).join(', '); + const citers = + index.inbound.get(record.id)?.get('cited_by') ?? ([] as string[]); + const causes = record.relations.derives_from ?? []; + for (const id of citers) + add(id, STRENGTH.citation, `cites ${record.id}: ${named}`); + for (const id of causes) + add(id, STRENGTH.citation, `${record.id} derives from it: ${named}`); + if (!citers.length && !causes.length) + add(record.id, STRENGTH.citation, `code citation: ${named}`); + } else if (namesTarget(record.body_excerpt, target)) + add(record.id, STRENGTH.text, `body names ${target.label}`); + } + for (const commit of commits) + for (const id of commit.ids) + add( + id, + STRENGTH.trailer, + `commit ${commit.sha.slice(0, 7)} "${ + commit.subject.length > 50 + ? `${commit.subject.slice(0, 49)}…` + : commit.subject + }"` + ); + return governed; +} + +/** + * Keeps every git read local. In a partial clone, git fetches a missing + * object from the promisor remote on first use; GIT_NO_LAZY_FETCH stops + * that on git 2.44 and later (and on builds that carry the change), and + * `protocol.allow=never` refuses every transport on older ones. + */ +export const NO_FETCH_ENV = { GIT_NO_LAZY_FETCH: '1' } as const; +export const NO_FETCH_ARGS = ['-c', 'protocol.allow=never'] as const; + +/** Runs git in `cwd`; resolves to stdout, or undefined when git fails. */ +export function git( + cwd: string, + args: readonly string[], + input?: string +): Promise { + return new Promise((resolveGit) => { + const child = execFile( + 'git', + [...NO_FETCH_ARGS, ...args], + { + cwd, + env: { ...process.env, ...NO_FETCH_ENV }, + maxBuffer: 512 * 1024 * 1024, + encoding: 'utf8', + }, + (error, stdout) => resolveGit(error ? undefined : stdout) + ); + if (input !== undefined) child.stdin?.end(input); + }); +} + +const trailerCache = new Map(); + +/** + * Commits reachable from `revs` that touch `path` and carry `Proof:` + * trailers, newest first. One trailer may list several ids, split on commas + * or spaces. Cached by the resolved commit shas and the path, so a moved + * branch reads again. + */ +export async function trailerCommits( + repoRoot: string, + revs: readonly string[], + path: string +): Promise { + // Names such as HEAD move, so the key holds the commits they name now. + const shas = (await git(repoRoot, ['rev-parse', ...revs])) + ?.split('\n') + .filter(Boolean); + if (!shas?.length) return []; + const key = `${repoRoot}\0${shas.join(',')}\0${path}`; + const cached = trailerCache.get(key); + if (cached) return cached; + const out = await git(repoRoot, [ + 'log', + '--format=%x1e%H%x1f%s%x1f%(trailers:key=Proof,valueonly,separator=%x2c)', + ...shas, + '--', + path || '.', + ]); + const commits: TrailerCommit[] = []; + for (const entry of (out ?? '').split('\x1e')) { + const [sha, subject, trailers] = entry.split('\x1f'); + if (!sha || !trailers?.trim()) continue; + const ids = trailers + .split(/[\s,]+/) + .map((id) => id.trim()) + .filter(Boolean); + if (ids.length) commits.push({ sha: sha.trim(), subject, ids }); + } + if (trailerCache.size > 64) trailerCache.clear(); + trailerCache.set(key, commits); + return commits; +} diff --git a/packages/flatbread/src/proof/find.ts b/packages/flatbread/src/proof/find.ts index e3146175..e04b03ae 100644 --- a/packages/flatbread/src/proof/find.ts +++ b/packages/flatbread/src/proof/find.ts @@ -459,6 +459,24 @@ export interface FindOptions { readonly asOf?: string; readonly retracted?: 'exclude' | 'include' | 'only'; readonly limit?: number; + /** + * Search only these records, each with what ties it to the query's code + * or ref (`--path`, `--refs`). Every chain that holds one is a candidate, + * even when no query word matches it. With words, BM25 ranks them; without, + * the strongest tie ranks them, then the newest shown version. There is no + * per-Effort cap. + */ + readonly governed?: ReadonlyMap; +} + +/** What ties a record to the code or ref a query names. */ +export interface FindVia { + /** citation 3, trailer 2, text 1; 0 for a record only a ref changed. */ + readonly strength: number; + /** The record the tie is on; a card for a newer version names it. */ + readonly record: string; + /** As the card prints it, such as `cites cit-a: src/a.ts#run@1a2b3c4`. */ + readonly text: string; } export interface FindLink { @@ -498,6 +516,15 @@ export interface FindHit { readonly outbound: readonly FindLink[]; readonly inbound: readonly FindLink[]; readonly code: readonly string[]; + /** With `governed`: the ties of every chain member, strongest first. */ + readonly via?: readonly FindVia[]; + /** With `--refs`: where the shown version lives, as `only on feat/a`. */ + readonly branch?: string; + /** + * With `--refs`, when branches each supersede one version: every head of + * the combined chain, which the chain line marks as rival heads. + */ + readonly heads?: readonly string[]; } /** A window of a body, with its offsets in the normalized body. */ @@ -532,6 +559,16 @@ export interface FindResult { readonly found: readonly string[]; /** Query words that no shown hit, nor a Blob a card quotes, contains. */ readonly missing: readonly string[]; + /** With `governed` and no query words: hits carry no score. */ + readonly unscored?: true; + /** Replaces the cards' first line, for `--path` and `--refs`. */ + readonly header?: string; + /** Lines after the header, such as the refs a query overlaid. */ + readonly notes?: readonly string[]; + /** With `--refs`: the first conflicts across the base and the refs. */ + readonly conflicts?: readonly string[]; + /** With `--refs`: how many conflicts there are in all. */ + readonly conflictsTotal?: number; } export interface QueryTerm { @@ -786,7 +823,7 @@ function inScope(record: ReadRecord, options: FindOptions): boolean { } /** Chain members that no other member supersedes, in link order. */ -function headsOf(chain: readonly ReadRecord[]): ReadRecord[] { +export function headsOf(chain: readonly ReadRecord[]): ReadRecord[] { const superseded = new Set( [...olderVersions(chain).values()].flatMap((ids) => [...ids]) ); @@ -893,11 +930,19 @@ export function findRecords( inScope(record, options) && (asOf === undefined || time(record) <= asOf); let searched = 0; const best = new Map(); + const governed = options.governed; for (const doc of index.docs.values()) { if (!eligible(doc.record)) continue; + if (governed) { + // Every eligible version of a governed chain is a candidate, so the + // card can show a head that the tie itself is not on. + const chainId = index.chainOf.get(doc.record.id) as string; + const chain = index.chains.get(chainId) as readonly ReadRecord[]; + if (!chain.some((member) => governed.has(member.id))) continue; + } searched++; const score = bm25(index, doc, termList); - if (score <= 0) continue; + if (score <= 0 && !governed) continue; const chainId = index.chainOf.get(doc.record.id) as string; const current = best.get(chainId); if ( @@ -909,28 +954,40 @@ export function findRecords( best.set(chainId, { record: doc.record, score }); } const ranked = [...best.entries()] - .flatMap(([chainId, { record: matched, score }]) => { + .flatMap(([chainId, { record: best, score }]) => { const chain = index.chains.get(chainId) as readonly ReadRecord[]; const heads = headsOf(chain); const head = heads.at(-1) ?? (chain.at(-1) as ReadRecord); const versions = chain.filter(eligible); // The card shows an eligible version: the newest head that is in // scope, else the newest version that is. With `asOf`, the last version - // in link order created by then. `matched` is always eligible. + // in link order created by then. `best`, the version that scored best, is always eligible. const record = (asOf === undefined ? heads.filter(eligible).at(-1) : undefined) ?? versions.at(-1) ?? - matched; - return [{ chain, versions, head, record, matched, score }]; + best; + // A governed chain no query word matched quotes the shown version. + const matched = score > 0 || !governed ? best : record; + const via = governed ? chainVias(chain, governed) : undefined; + return [{ chain, versions, head, record, matched, score, via }]; }) - .sort((a, b) => b.score - a.score || compareIds(a.head.id, b.head.id)); + .sort( + governed + ? (a, b) => + b.score - a.score || + (b.via?.[0]?.strength ?? 0) - (a.via?.[0]?.strength ?? 0) || + time(b.record) - time(a.record) || + compareIds(a.head.id, b.head.id) + : (a, b) => b.score - a.score || compareIds(a.head.id, b.head.id) + ); const effortOf = (record: ReadRecord) => typeof record.frontmatter.effort === 'string' ? record.frontmatter.effort : null; let selected = ranked; let efforts: FindEffortMass[] | undefined; - if (!options.effort) { + if (governed) selected = selected.slice(0, limit); + else if (!options.effort) { const perEffort = new Map(); for (const hit of ranked) { const effort = effortOf(hit.record) ?? ''; @@ -1041,6 +1098,7 @@ export function findRecords( outbound: outboundLinks(hit.record), inbound: inboundLinks(index, hit.record), code, + ...(hit.via ? { via: hit.via } : {}), }; }); // A hyphenated query word is searched joined and split. When every part @@ -1060,9 +1118,35 @@ export function findRecords( ...(efforts ? { efforts: efforts.slice(0, EFFORTS_SHOWN) } : {}), found: surfaces((entry) => foundTerms.has(entry.term)), missing: surfaces(missed), + ...(governed && !termList.length ? { unscored: true as const } : {}), }; } +/** + * The ties of a chain's members, strongest first, then in text order, each + * text once. + */ +function chainVias( + chain: readonly ReadRecord[], + governed: ReadonlyMap +): FindVia[] { + const seen = new Set(); + return chain + .flatMap((member) => governed.get(member.id) ?? []) + .sort( + (a, b) => + b.strength - a.strength || + compareIds(a.text, b.text) || + compareIds(a.record, b.record) + ) + .filter((via) => { + const key = `${via.text}\0${via.record}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + function cut(value: string, max: number): string { const flat = value.replace(/\s+/g, ' ').trim(); return [...flat].length > max @@ -1149,7 +1233,7 @@ function chainLine(hit: FindHit, shape: CardShape): string | undefined { if (skipped) parts.push(`(${skipped} more)`); skipped = 0; const marks = [ - member.id === hit.head.id ? 'head' : '', + member.id === hit.head.id || hit.heads?.includes(member.id) ? 'head' : '', member.id === hit.record.id && member.id !== hit.head.id ? 'as of' : '', ].filter(Boolean); const killer = [ @@ -1179,9 +1263,34 @@ function attachedLine(attached: NonNullable): string { }" [chars ${passage.start}-${passage.end} of ${passage.length}]`; } -/** Characters of a card's passage lines, with their line breaks. */ +const VIAS_SHOWN = 3; + +/** The `via:` and `branch:` lines, which are never cut. */ +function tieLines(hit: FindHit): string[] { + const lines: string[] = []; + const vias = (hit.via ?? []).filter((via) => via.text); + if (vias.length) + lines.push( + ` via: ${vias + .slice(0, VIAS_SHOWN) + .map( + (via) => + `${via.text}${ + via.record !== hit.record.id ? ` (on ${via.record})` : '' + }` + ) + .join('; ')}${ + vias.length > VIAS_SHOWN ? `; +${vias.length - VIAS_SHOWN} more` : '' + }` + ); + if (hit.branch) lines.push(` branch: ${hit.branch}`); + return lines; +} + +/** Characters of a card's passage and tie lines, with their line breaks. */ function passageChars(hit: FindHit): number { const lines = [ + ...tieLines(hit), ...hit.passages.map(passageLine), ...(hit.attached ? [attachedLine(hit.attached)] : []), ]; @@ -1192,7 +1301,8 @@ function renderCard( index: FindIndex, hit: FindHit, n: number, - shape: CardShape + shape: CardShape, + unscored = false ): string[] { const { record } = hit; const standing = standingOf(record); @@ -1203,7 +1313,7 @@ function renderCard( retracted ? 'RETRACTED' : standing }${ record.id === hit.head.id && hit.chain.length > 1 ? ' (head)' : '' - } | ${date(record)} | score ${hit.score.toFixed(1)}`, + } | ${date(record)}${unscored ? '' : ` | score ${hit.score.toFixed(1)}`}`, ` "${cut(text(record.frontmatter.title), 100)}"`, ...(retracted ? [ @@ -1217,6 +1327,7 @@ function renderCard( )}; not live, kept for audit only`, ] : []), + ...tieLines(hit), ...(hit.matched.id !== record.id ? [` matched: v${version + 1} ${hit.matched.id}`] : []), @@ -1262,16 +1373,35 @@ const SHRINK_STEPS: readonly (CardShape & { efforts: boolean })[] = [ */ export function renderFindCards(index: FindIndex, result: FindResult): string { const scope = result.effort ? `in ${result.effort}` : 'in all Efforts'; + // Notes and conflicts are never cut, so they count apart too. + const conflictTotal = result.conflictsTotal ?? result.conflicts?.length ?? 0; + const conflictLines = result.conflicts + ? conflictTotal + ? [ + `CONFLICTS (${conflictTotal})`, + ...result.conflicts.map((conflict) => `- ${conflict}`), + ...(conflictTotal > result.conflicts.length + ? [`- +${conflictTotal - result.conflicts.length} more`] + : []), + ] + : ['CONFLICTS: none'] + : []; const render = (shape: typeof SHRINK_STEPS[number]) => { const lines = [ - `find "${cut(result.query, 120)}" ${scope} (${ - result.searched - } records searched, ${result.matched} matched, ${ - result.hits.length - } shown)`, - `matched: ${result.found.join(' ') || 'none'} | missing: ${ - result.missing.join(' ') || 'none' - }`, + result.header ?? + `find "${cut(result.query, 120)}" ${scope} (${ + result.searched + } records searched, ${result.matched} matched, ${ + result.hits.length + } shown)`, + ...(result.unscored + ? [] + : [ + `matched: ${result.found.join(' ') || 'none'} | missing: ${ + result.missing.join(' ') || 'none' + }`, + ]), + ...(result.notes ?? []), ]; if (shape.efforts && result.efforts?.length) lines.push( @@ -1295,15 +1425,20 @@ export function renderFindCards(index: FindIndex, result: FindResult): string { )}"` ); } - lines.push(...renderCard(index, hit, i + 1, shape)); + lines.push(...renderCard(index, hit, i + 1, shape, result.unscored)); }); if (!result.hits.length) lines.push('no records matched'); + lines.push(...conflictLines); return lines.join('\n'); }; const limit = CARDS_HEADER_CHARS + CARD_CHARS * result.hits.length + - result.hits.reduce((sum, hit) => sum + passageChars(hit), 0); + result.hits.reduce((sum, hit) => sum + passageChars(hit), 0) + + [...(result.notes ?? []), ...conflictLines].reduce( + (sum, line) => sum + line.length + 1, + 0 + ); let markdown = ''; for (const shape of SHRINK_STEPS) { markdown = render(shape); diff --git a/packages/flatbread/src/proof/read.ts b/packages/flatbread/src/proof/read.ts index 87a472cb..5aa74577 100644 --- a/packages/flatbread/src/proof/read.ts +++ b/packages/flatbread/src/proof/read.ts @@ -37,8 +37,32 @@ import { findRecords, renderFindCards, standingOf, + headsOf, + type FindHit, type FindIndex, + type FindResult, + type FindVia, } from './find.js'; +import { + git, + governingRecords, + parseCodeTarget, + trailerCommits, + type CodeTarget, + type TrailerCommit, +} from './anchors.js'; +import { + branchCandidates, + buildOverlay, + changeCounts, + conflictsInScope, + pathInTrees, + proofRepository, + readRefSides, + refsLine, + type Overlay, + type RefSide, +} from './refs.js'; import { buildEffortState, DEAD_STANDINGS, @@ -364,6 +388,10 @@ class EngineProjection { readonly provider: FlatbreadProvider; private fieldsPromise?: Promise>>; private findIndexPromise?: Promise; + private overlays = new Map< + string, + Promise<{ overlay: Overlay; index: FindIndex }> + >(); constructor(config: LoadedFlatbreadConfig) { this.provider = new FlatbreadProvider(config); @@ -462,6 +490,42 @@ class EngineProjection { return this.findIndexPromise; } + /** + * The `find` index over the base records and the given refs, kept for the + * last few ref sets. Keyed by each ref's commit, so a moved ref builds anew. + */ + overlayIndex( + sides: readonly RefSide[] + ): Promise<{ overlay: Overlay; index: FindIndex }> { + // The pull request and the per-side id sets go in the key too: tags and + // conflicts read them, and the sets follow the working tree. + const sorted = (set?: ReadonlySet) => [...(set ?? [])].sort(); + const key = sides + .map( + (side) => + `${side.name}@${side.sha}${JSON.stringify([ + side.pr ?? {}, + sorted(side.baseChanged), + sorted(side.sameAsBase), + sorted(side.older), + sorted(side.deleted), + ])}` + ) + .join('\0'); + let found = this.overlays.get(key); + if (!found) { + found = this.findIndex().then((base) => { + const overlay = buildOverlay([...base.records.values()], sides); + return { overlay, index: buildFindIndex(overlay.records) }; + }); + found.catch(() => this.overlays.delete(key)); + if (this.overlays.size >= 4) + this.overlays.delete(this.overlays.keys().next().value as string); + this.overlays.set(key, found); + } + return found; + } + async one( collection: Collection, id: string @@ -1370,7 +1434,33 @@ export interface FindEnvelope extends ReadEnvelope { /** Searching every Effort: the Efforts whose best 3 hits score highest. */ efforts?: { id: string; mass: number }[]; /** In card order; `matched_id` names an older version when it matched. */ - hits: { id: string; matched_id?: string; score: number }[]; + hits: { + id: string; + matched_id?: string; + score: number; + /** With `--path`: what ties the hit's chain to the code. */ + via?: string[]; + /** With `--refs`: `main`, `only on `, or `differs on : …`. */ + branch?: string; + }[]; + /** With `--path`: the code searched, as the header prints it. */ + path?: string; + /** + * With `--path` and no hits: the path is not in the working tree, and + * which refs have it. + */ + note?: string; + /** With `--refs`: the refs overlaid. */ + refs?: { + name: string; + sha: string; + changed: number; + pr?: { number: number; draft: boolean; author: string }; + }[]; + /** With `--refs`: the first 12 conflicts in scope, as the cards list them. */ + conflicts?: string[]; + /** With `--refs`: how many conflicts there are in scope. */ + conflicts_total?: number; }; /** The hit cards as Markdown, so the agent needs no second Read. */ cards: string; @@ -1412,6 +1502,10 @@ export async function effortFind( asOf?: string; retracted?: RetractedMode; limit?: number; + /** `--path`: a file, folder, symbol, or `file#symbol`. */ + path?: string; + /** `--refs`: ref names, or `all`. */ + refs?: readonly string[] | 'all'; } ): Promise { const invalid = (message: string): never => { @@ -1445,18 +1539,158 @@ export async function effortFind( )}. Expected ${FIND_KINDS.join(', ')}.` ); const text = String(query ?? '').trim(); - if (!text) invalid('find needs at least one word to search for'); + const codePath = options.path?.trim() || undefined; + if (options.path !== undefined && !codePath) + invalid('--path needs a file, folder, or symbol'); + const refs = + options.refs === 'all' + ? 'all' + : options.refs?.length + ? options.refs + : undefined; + if (options.refs !== undefined && !refs) + invalid('--refs needs ref names or all'); + if (!text && !codePath && !refs) + invalid('find needs at least one word to search for'); const projection = await makeProjection(options); - const index = await projection.findIndex(); + let index = await projection.findIndex(); + let overlay: Overlay | undefined; + let refSet: Awaited> | undefined; + let sides: RefSide[] = []; + if (refs) { + refSet = await readRefSides(options.rootDir, options.cacheRoot, refs); + sides = refSet.sides; + ({ overlay, index } = await projection.overlayIndex(sides)); + } + const conflicts = overlay + ? conflictsInScope(overlay, options.effort) + : undefined; if (options.effort && index.records.get(options.effort)?.kind !== 'effort') invalid(`Effort ${options.effort} does not exist`); - const result = findRecords(index, text, { + let governed: Map | undefined; + let pathLabel: string | undefined; + let pathNote: (() => Promise) | undefined; + if (codePath) { + const repo = await proofRepository(options.rootDir); + // For a path the working tree lacks: the refs that hold it, from the + // named refs, or else from the branches `--refs all` looks at. Each + // path costs one `cat-file --batch-check`, and only when asked. + let pool: Promise<{ name: string; sha: string }[]> | undefined; + const refPool = () => + (pool ??= !repo + ? Promise.resolve([]) + : refs && refs !== 'all' + ? Promise.resolve(sides.map(({ name, sha }) => ({ name, sha }))) + : git(repo.top, ['rev-parse', '--verify', '-q', 'HEAD']) + .then((head) => + branchCandidates(repo.top, head?.trim() || undefined) + ) + .then((list) => + list.map(({ short, sha }) => ({ name: short, sha })) + )); + const held = new Map< + string, + Promise<{ name: string; sha: string; kind: 'file' | 'dir' }[]> + >(); + const holdersOf = (path: string) => { + let list = held.get(path); + if (!list) { + list = refPool().then(async (pooled) => { + const kinds = repo + ? await pathInTrees( + repo.top, + pooled.map(({ sha }) => sha), + path + ) + : new Map(); + return pooled.flatMap((ref) => { + const kind = kinds.get(ref.sha); + return kind ? [{ ...ref, kind }] : []; + }); + }); + held.set(path, list); + } + return list; + }; + // With --refs, a path only a ref has is read from that ref's tree. + const target = await parseCodeTarget( + codePath, + options.cwd, + repo?.top ?? options.cwd, + refs && repo + ? async (path) => (await holdersOf(path))[0]?.kind + : undefined + ); + pathLabel = target.label; + // Trailers need a path; a bare symbol has none to give git log. With + // --refs, the commits of every ref that holds the path count too, so a + // branch that changes code and no record still ties its trailers. + const holders = + refs && target.absent && target.path !== undefined + ? await holdersOf(target.path) + : []; + const commits: TrailerCommit[] = + repo && target.path !== undefined + ? await trailerCommits( + repo.top, + [ + ...new Set([ + 'HEAD', + ...sides.map((side) => side.sha), + ...holders.map((holder) => holder.sha), + ]), + ], + target.path + ) + : []; + pathNote = () => codeTargetNote(target, codePath, refs, repo, holdersOf); + governed = governingRecords( + index, + target, + commits, + (record) => + retracted !== 'exclude' || record.frontmatter.retracted !== true + ); + } else if (overlay && !text) + governed = new Map( + [...overlay.proposed].map((id) => [ + id, + [{ strength: 0, record: id, text: '' }], + ]) + ); + const found = findRecords(index, text, { effort: options.effort, kinds, asOf: options.asOf, retracted, limit, + ...(governed ? { governed } : {}), }); + const note = pathNote && !found.matched ? await pathNote() : undefined; + const result: FindResult = + codePath || overlay + ? { + ...found, + header: findHeader( + text, + pathLabel, + options.refs, + found, + options.effort + ), + ...(note ? { notes: [note] } : {}), + ...(overlay && refSet && conflicts + ? { + notes: [refsLine(refSet), ...(note ? [note] : [])], + hits: found.hits.map((hit) => + branchOf(overlay as Overlay, hit) + ), + conflicts: conflicts.texts, + conflictsTotal: conflicts.total, + } + : {}), + } + : found; const cards = renderFindCards(index, result); const envelope = await render( options, @@ -1468,6 +1702,8 @@ export async function effortFind( ...(options.asOf ? { as_of: options.asOf } : {}), ...(retracted === 'exclude' ? {} : { retracted }), limit, + ...(codePath ? { path: codePath } : {}), + ...(refs ? { refs: refs === 'all' ? 'all' : [...refs].sort() } : {}), }, result.hits.map((hit) => hit.record), [], @@ -1482,6 +1718,8 @@ export async function effortFind( result.searched } searched); matched: ${result.found.join(' ') || 'none'}; missing: ${ result.missing.join(' ') || 'none' + }${conflicts ? `; refs ${sides.length}; conflicts ${conflicts.total}` : ''}${ + note ? `; ${note}` : '' }`; return { ...envelope, @@ -1510,12 +1748,140 @@ export async function effortFind( ? { matched_id: hit.matched.id } : {}), score: roundScore(hit.score), + ...(codePath + ? { + via: (hit.via ?? []).map((via) => + via.record === hit.record.id + ? via.text + : `${via.text} (on ${via.record})` + ), + } + : {}), + ...(hit.branch ? { branch: hit.branch } : {}), })), + ...(pathLabel ? { path: pathLabel } : {}), + ...(note ? { note } : {}), + ...(overlay + ? { + refs: sides.map((side) => ({ + name: side.name, + sha: side.sha, + changed: changeCounts(side).records, + ...(side.pr + ? { + pr: { + number: side.pr.number, + draft: side.pr.isDraft, + author: side.pr.author, + }, + } + : {}), + })), + conflicts: conflicts?.texts ?? [], + conflicts_total: conflicts?.total ?? 0, + } + : {}), }, cards, }; } +/** + * A hit with its `branch:` line. When branches each supersede one version, + * the combined chain has several heads; the line then names every other + * head and where it lives, and the chain line marks each head. + */ +function branchOf(overlay: Overlay, hit: FindHit): FindHit { + const heads = headsOf(hit.chain); + const tag = overlay.tag(hit.record.id); + if (heads.length < 2) return { ...hit, branch: tag }; + const others = heads.filter((head) => head.id !== hit.record.id); + return { + ...hit, + branch: `${tag}; rival heads: ${others + .map((head) => `${head.id} (${overlay.tag(head.id)})`) + .join(', ')}`, + heads: heads.map((head) => head.id), + }; +} + +const REFS_ALL_HINT = + '--refs all overlays branches, with their records and commit trailers.'; + +/** Ref names for a note: the first five, then how many more. */ +function refNames(list: readonly { name: string }[]): string { + const names = list.slice(0, 5).map((ref) => ref.name); + return `${names.join(', ')}${ + list.length > 5 ? ` and ${list.length - 5} more` : '' + }`; +} + +/** + * Why `--path` found nothing, when the working tree lacks the path: which + * refs have it, and that `--refs all` reads them. A bare word read as a + * symbol gets a note only when a branch holds it as a path. + */ +async function codeTargetNote( + target: CodeTarget, + value: string, + refs: readonly string[] | 'all' | undefined, + repo: { top: string } | undefined, + holdersOf: (path: string) => Promise<{ name: string }[]> +): Promise { + const { label } = target; + if (target.path === undefined) { + if (refs || !repo || value.includes('#') || value.endsWith('()')) + return undefined; + const list = await holdersOf(label); + return list.length + ? `note: ${label} is not in the working tree; ${refNames(list)} ${ + list.length > 1 ? 'have' : 'has' + } it as a path. ${REFS_ALL_HINT}` + : undefined; + } + if (!target.absent) return undefined; + if (!repo) return `note: ${label} is not in the working tree.`; + const list = await holdersOf(target.path); + if (!refs) + return list.length + ? `note: ${label} is not in the working tree; ${refNames(list)} ${ + list.length > 1 ? 'have' : 'has' + } it. ${REFS_ALL_HINT}` + : `note: ${label} is not in the working tree or on any branch. ${REFS_ALL_HINT}`; + if (list.length) + return `note: ${label} is not in the working tree; read from ${refNames( + list + )}.`; + return refs === 'all' + ? `note: ${label} is not in the working tree or on any branch.` + : `note: ${label} is not in the working tree or on ${refs.join( + ', ' + )}; --refs all overlays every branch.`; +} + +/** The first card line for `--path` and `--refs`. */ +function findHeader( + text: string, + path: string | undefined, + refs: readonly string[] | 'all' | undefined, + result: FindResult, + effort: string | undefined +): string { + const words = text + ? ` "${text.length > 120 ? `${text.slice(0, 119)}…` : text}"` + : ''; + const flags = `${path ? ` --path ${path}` : ''}${ + refs ? ` --refs ${refs === 'all' ? 'all' : refs.join(',')}` : '' + }`; + const scope = effort ? `in ${effort}` : 'in all Efforts'; + const counts = path + ? `${result.matched} governing, ${result.hits.length} shown` + : !text + ? `${result.matched} changed on refs, ${result.hits.length} shown` + : `${result.searched} records searched, ${result.matched} matched, ${result.hits.length} shown`; + return `find${words}${flags} ${scope} (${counts})`; +} + /** A `state` envelope: the usual read fields plus the state inline. */ export interface StateEnvelope extends ReadEnvelope { /** Exact counts and how much of each list is shown. */ diff --git a/packages/flatbread/src/proof/refs.test.ts b/packages/flatbread/src/proof/refs.test.ts new file mode 100644 index 00000000..a422bed7 --- /dev/null +++ b/packages/flatbread/src/proof/refs.test.ts @@ -0,0 +1,379 @@ +import test from 'ava'; +import type { PrimitiveKind, ReadRecord } from '@flatbread/proof'; +import { + buildOverlay, + conflictsInScope, + matchPullRequest, + recordFromFile, + type RefSide, +} from './refs.js'; + +const KINDS: Record = { + eff: 'effort', + iss: 'issue', + fnd: 'finding', + dec: 'decision', + cit: 'citation', +}; + +function rec( + id: string, + fields: Record & { + relations?: ReadRecord['relations']; + } = {} +): ReadRecord { + const kind = KINDS[id.split('-', 1)[0]]; + const { relations, ...frontmatter } = fields; + return { + id, + kind, + path: `${id}.md`, + frontmatter: { + title: id, + created_at: '2026-01-01T00:00:00.000Z', + ...(kind === 'effort' ? {} : { effort: 'eff-a' }), + ...frontmatter, + }, + body_excerpt: '', + relations: relations ?? {}, + }; +} + +/** + * A ref holding the base ids plus its own records. `baseChanged` names ids + * the working tree also changed since the ref split off. + */ +function side( + name: string, + base: readonly ReadRecord[], + changed: readonly ReadRecord[], + extra: Partial = {} +): RefSide { + return { + name, + sha: `${name.length}`.padEnd(40, '0'), + ids: new Set([...base, ...changed].map((record) => record.id)), + changed: new Map(changed.map((record) => [record.id, record])), + baseChanged: new Set(), + sameAsBase: new Set(), + ...extra, + }; +} + +const question = rec('iss-q', { kind: 'question', status: 'open' }); + +test('tags say main, only on a ref, or how a ref differs, with its draft PR', (t) => { + const base = [rec('eff-a'), question, rec('dec-a', { state: 'proposed' })]; + const overlay = buildOverlay(base, [ + side('feat/x', base, [ + rec('dec-a', { state: 'accepted' }), + rec('dec-b', { state: 'proposed' }), + ]), + side('feat/y', base, [], { + pr: { number: 7, headRefName: 'feat/y', isDraft: true, author: 'ana' }, + }), + ]); + t.is(overlay.tag('iss-q'), 'main'); + t.is(overlay.tag('dec-b'), 'only on feat/x'); + t.is(overlay.tag('dec-a'), 'differs on feat/x: proposed vs accepted'); + t.deepEqual([...overlay.proposed].sort(), ['dec-a', 'dec-b']); + t.deepEqual(overlay.conflicts, []); + const drafted = buildOverlay(base, [ + side('origin/feat/y', base, [rec('dec-c')], { + pr: { number: 7, headRefName: 'feat/y', isDraft: true, author: 'ana' }, + }), + ]); + t.is(drafted.tag('dec-c'), 'only on origin/feat/y (draft #7 by ana)'); +}); + +test('reverse links come from forward edges, never from stored reverse fields', (t) => { + const base = [ + rec('eff-a'), + // A stale stored reverse field the forward edges do not back. + rec('fnd-a', { relations: { superseded_by: ['fnd-ghost'] } }), + ]; + const overlay = buildOverlay(base, [ + side('feat/x', base, [ + rec('fnd-b', { relations: { supersedes: ['fnd-a'] } }), + ]), + ]); + const a = overlay.records.find((record) => record.id === 'fnd-a'); + t.deepEqual(a?.relations.superseded_by, ['fnd-b']); + t.is(overlay.tag('fnd-a'), 'main'); + // With no ref, the stored field is dropped too. + t.is( + buildOverlay(base, []).records.find((record) => record.id === 'fnd-a') + ?.relations.superseded_by, + undefined + ); +}); + +test('conflict: two accepted Decisions on one question across refs', (t) => { + const base = [rec('eff-a'), question]; + const decision = (id: string) => + rec(id, { state: 'accepted', relations: { derives_from: ['iss-q'] } }); + const overlay = buildOverlay(base, [ + side('feat/x', base, [decision('dec-x')]), + side('feat/y', base, [decision('dec-y')]), + ]); + t.deepEqual( + overlay.conflicts.map((conflict) => conflict.text), + ['two accepted Decisions on question iss-q: dec-x (feat/x), dec-y (feat/y)'] + ); + t.is(overlay.conflicts[0].effort, 'eff-a'); + // A ref that accepts its own Decision and rejects the base's is a proposal. + const accepted = decision('dec-base'); + const replaced = buildOverlay( + [...base, accepted], + [ + side( + 'feat/x', + [...base, accepted], + [ + decision('dec-x'), + rec('dec-base', { + state: 'rejected', + relations: { derives_from: ['iss-q'], rejected_by: ['dec-x'] }, + }), + ] + ), + ] + ); + t.deepEqual(replaced.conflicts, []); +}); + +test('conflict: a Decision one ref accepts and another rejects, proposed on the base', (t) => { + const proposed = rec('dec-x', { + state: 'proposed', + relations: { derives_from: ['iss-q'] }, + }); + const base = [rec('eff-a'), question, proposed]; + const decision = (id: string) => + rec(id, { state: 'accepted', relations: { derives_from: ['iss-q'] } }); + const overlay = buildOverlay(base, [ + side('feat/a', base, [decision('dec-x')]), + side('feat/b', base, [ + decision('dec-y'), + rec('dec-x', { + state: 'rejected', + relations: { derives_from: ['iss-q'], rejected_by: ['dec-y'] }, + }), + ]), + ]); + // The base never accepted dec-x, so feat/b rejecting it does not settle + // feat/a's acceptance: the two refs answer the question differently. + t.true( + overlay.conflicts.some( + (conflict) => + conflict.text === + 'two accepted Decisions on question iss-q: dec-x (feat/a), dec-y (feat/b)' + ), + overlay.conflicts.map((conflict) => conflict.text).join('\n') + ); +}); + +test('conflict: two accepted Decisions that resolve one question, with no derives_from', (t) => { + const base = [rec('eff-a'), question]; + const accepted = (id: string) => rec(id, { state: 'accepted' }); + const resolved = (by: string) => + rec('iss-q', { + kind: 'question', + status: 'resolved', + relations: { resolved_by: [by] }, + }); + const overlay = buildOverlay(base, [ + side('feat/x', base, [accepted('dec-x'), resolved('dec-x')]), + side('feat/y', base, [accepted('dec-y'), resolved('dec-y')]), + ]); + // One line: rule 1 names the question, so the resolved_by rule stays quiet. + t.deepEqual( + overlay.conflicts.map((conflict) => conflict.text), + ['two accepted Decisions on question iss-q: dec-x (feat/x), dec-y (feat/y)'] + ); + // Each Decision card lists the question it resolves. + t.deepEqual( + overlay.records.find((record) => record.id === 'iss-q')?.relations + .resolved_by, + ['dec-x', 'dec-y'] + ); + // Resolved by records that are not accepted Decisions: the Issue rule. + const findings = buildOverlay(base, [ + side('feat/x', base, [rec('fnd-x'), resolved('fnd-x')]), + side('feat/y', base, [rec('fnd-y'), resolved('fnd-y')]), + ]); + t.deepEqual( + findings.conflicts.map((conflict) => conflict.text), + ['iss-q resolved differently: by fnd-x on feat/x; by fnd-y on feat/y'] + ); +}); + +test('an older version of another ref, as origin/feat/x behind feat/x, makes no conflict', (t) => { + const base = [rec('eff-a')]; + const overlay = buildOverlay(base, [ + side('feat/x', base, [rec('dec-x', { state: 'accepted' })]), + side('origin/feat/x', base, [rec('dec-x', { state: 'proposed' })], { + older: new Set(['dec-x']), + }), + ]); + t.deepEqual(overlay.conflicts, []); + t.is(overlay.tag('dec-x'), 'only on feat/x'); + t.is( + overlay.records.find((record) => record.id === 'dec-x')?.frontmatter.state, + 'accepted' + ); +}); + +test('a record a ref deletes is tagged and proposed', (t) => { + const base = [rec('eff-a'), rec('fnd-a')]; + const overlay = buildOverlay(base, [ + side('feat/x', [rec('eff-a')], [], { deleted: new Set(['fnd-a']) }), + ]); + t.is(overlay.tag('fnd-a'), 'deleted on feat/x'); + t.true(overlay.proposed.has('fnd-a')); +}); + +test('a pull request matches its branch exactly, or behind a remote name', (t) => { + const prs = [ + { number: 1, headRefName: 'x', isDraft: true, author: 'bo' }, + { number: 2, headRefName: 'feat/x', isDraft: true, author: 'ana' }, + ]; + t.is(matchPullRequest('feat/x', prs, ['origin'])?.number, 2); + t.is(matchPullRequest('origin/feat/x', prs, ['origin'])?.number, 2); + t.is(matchPullRequest('refs/remotes/origin/x', prs, ['origin'])?.number, 1); + t.is(matchPullRequest('other/x', prs, ['origin']), undefined); +}); + +test('conflicts in scope are capped, with the total', (t) => { + const base = [rec('eff-a')]; + const changed = Array.from({ length: 15 }, (_, i) => + rec(`iss-${String(i).padStart(2, '0')}`, { status: 'resolved' }) + ); + const overlay = buildOverlay( + [ + ...base, + ...changed.map((record) => ({ + ...record, + frontmatter: { ...record.frontmatter, status: 'open' }, + })), + ], + [ + side('feat/x', base, changed, { + baseChanged: new Set(changed.map((record) => record.id)), + }), + ] + ); + const { texts, total } = conflictsInScope(overlay, 'eff-a'); + t.is(texts.length, 12); + t.is(total, 15); + t.is(conflictsInScope(overlay, 'eff-b').total, 0); +}); + +test('conflict: one record superseded by two different records', (t) => { + const base = [rec('eff-a'), rec('fnd-a')]; + const overlay = buildOverlay(base, [ + side('feat/x', base, [ + rec('fnd-x', { relations: { supersedes: ['fnd-a'] } }), + ]), + side('feat/y', base, [ + rec('fnd-y', { relations: { supersedes: ['fnd-a'] } }), + ]), + ]); + t.deepEqual( + overlay.conflicts.map((conflict) => conflict.text), + ['fnd-a superseded by two records: fnd-x (feat/x), fnd-y (feat/y)'] + ); +}); + +test('conflict: one id with a different state on two sides that both changed it', (t) => { + const base = [rec('eff-a'), rec('iss-a', { status: 'wontfix' })]; + const resolved = rec('iss-a', { status: 'resolved' }); + // The working tree changed it too since feat/x split off: a conflict. + const both = buildOverlay(base, [ + side('feat/x', base, [resolved], { baseChanged: new Set(['iss-a']) }), + ]); + t.deepEqual( + both.conflicts.map((conflict) => conflict.text), + ['iss-a has different status: wontfix on base; resolved on feat/x'] + ); + // Only the ref changed it: a proposal, shown on the tag. + const proposal = buildOverlay(base, [side('feat/x', base, [resolved])]); + t.deepEqual(proposal.conflicts, []); + t.is(proposal.tag('iss-a'), 'differs on feat/x: wontfix vs resolved'); + // Two refs that each changed it differently conflict with each other. + const refs = buildOverlay(base, [ + side('feat/x', base, [resolved]), + side('feat/y', base, [rec('iss-a', { status: 'deferred' })]), + ]); + t.deepEqual( + refs.conflicts.map((conflict) => conflict.text), + ['iss-a has different status: resolved on feat/x; deferred on feat/y'] + ); +}); + +test('conflict: a retraction on one side of a record another side relies on', (t) => { + const base = [rec('eff-a'), rec('fnd-a')]; + const overlay = buildOverlay(base, [ + side('feat/x', base, [ + rec('fnd-a', { retracted: true, retracted_reason: 'wrong rig' }), + ]), + side('feat/y', base, [ + rec('dec-y', { + state: 'proposed', + relations: { derives_from: ['fnd-a'] }, + }), + ]), + ]); + t.deepEqual( + overlay.conflicts.map((conflict) => conflict.text), + ['fnd-a retracted on feat/x; dec-y on feat/y relies on it (derives_from)'] + ); + // A ref that retracts a Finding and the Decision on it: the base's + // Decision held that reliance before the split, so no conflict. A base + // Decision added after the split is one. + const relying = rec('dec-a', { relations: { derives_from: ['fnd-a'] } }); + const later = rec('dec-b', { relations: { derives_from: ['fnd-a'] } }); + const both = buildOverlay( + [...base, relying, later], + [ + side( + 'feat/r', + [...base, relying], + [rec('fnd-a', { retracted: true }), rec('dec-a', { retracted: true })], + { baseChanged: new Set(['dec-b']) } + ), + ] + ); + t.deepEqual( + both.conflicts.map((conflict) => conflict.text), + ['fnd-a retracted on feat/r; dec-b on base relies on it (derives_from)'] + ); +}); + +test('conflict: two head compactions for one Effort', (t) => { + const compaction = (id: string, extra: ReadRecord['relations'] = {}) => + rec(id, { kind: 'compaction', relations: extra }); + const base = [rec('eff-a'), compaction('fnd-c0')]; + const overlay = buildOverlay(base, [ + side('feat/x', base, [compaction('fnd-cx', { supersedes: ['fnd-c0'] })]), + side('feat/y', base, [compaction('fnd-cy', { supersedes: ['fnd-c0'] })]), + ]); + // Rule 2 leaves compactions to this rule, so one line, not two. + t.deepEqual( + overlay.conflicts.map((conflict) => conflict.text), + ['eff-a has 2 head compactions: fnd-cx (feat/x), fnd-cy (feat/y)'] + ); +}); + +test('a record file parses as the find index reads it', (t) => { + const record = recordFromFile( + '.flatbread-proof/decisions/dec-a.md', + "---\nid: dec-a--0123456789abcdef\neffort: eff-a\ntitle: Pick A\nstate: accepted\ncreated_at: '2026-01-02T00:00:00.000Z'\nderives_from:\n - iss-q\n---\nBody text.\n" + ); + t.like(record, { + id: 'dec-a--0123456789abcdef', + kind: 'decision', + frontmatter: { effort: 'eff-a', state: 'accepted' }, + relations: { derives_from: ['iss-q'] }, + }); + t.is(record?.body_excerpt.trim(), 'Body text.'); +}); diff --git a/packages/flatbread/src/proof/refs.ts b/packages/flatbread/src/proof/refs.ts new file mode 100644 index 00000000..c1a5ca6c --- /dev/null +++ b/packages/flatbread/src/proof/refs.ts @@ -0,0 +1,1354 @@ +import { createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { readFile, realpath, writeFile, mkdir } from 'node:fs/promises'; +import { dirname, join, relative, sep } from 'node:path'; +import { + isCompaction, + parseDocument, + ProofReadValidationError, + type PrimitiveKind, + type ReadRecord, + type ReadRelation, +} from '@flatbread/proof'; +import { standingOf } from './find.js'; +import { git, NO_FETCH_ARGS, NO_FETCH_ENV } from './anchors.js'; + +/** + * `proof find --refs`: records from other git refs, read without a checkout. + * Each ref's Proof folder is listed with `git ls-tree` and read with + * `git cat-file --batch`. A ref counts only for the records it changed since + * its merge base with HEAD, so a branch that is merely behind the working + * tree adds nothing and reports no conflicts. A version already in HEAD's + * history does not count either, so a branch that was squash-merged and + * left behind adds nothing. + * + * The combined graph takes each record from the working tree when it is + * there, else from the first ref that has it. Supersession and + * invalidation are rebuilt from the forward edges (`supersedes`, + * `invalidates`) of every version on every side; the stored reverse fields + * (`superseded_by`, `invalidated_by`) on either side are ignored. An + * Issue's `resolved_by` is the union over every version, so a Decision that + * resolves a question only on a branch still lists it. The other reverse + * links a card lists (rejects, cited_by, ...) are built by the `find` index + * from the combined records. + * + * No git command here fetches. In a partial clone, a record whose blob is + * not on disk is left out and counted on the refs line. + */ + +/** Most refs `--refs all` overlays: the newest that change Proof records. */ +export const REFS_ALL_LIMIT = 12; +/** Most refs `--refs all` looks at, newest commit first. */ +export const REFS_ALL_EXAMINED = 50; +export const CONFLICTS_SHOWN = 12; +const BASE = 'base'; + +export interface PullRequest { + readonly number: number; + readonly headRefName: string; + readonly isDraft: boolean; + readonly author: string; +} + +/** One ref, as the overlay reads it. */ +export interface RefSide { + /** The ref as given, or its short name for `all`. */ + readonly name: string; + readonly sha: string; + /** Every record id in the ref's Proof folder. */ + readonly ids: ReadonlySet; + /** + * The versions the ref changed since its merge base with HEAD, leaving + * out versions already in HEAD's history. + */ + readonly changed: ReadonlyMap; + /** The ids the working tree changed or added since that merge base. */ + readonly baseChanged: ReadonlySet; + /** Of `changed`, the ids whose version is byte for byte the working tree's. */ + readonly sameAsBase: ReadonlySet; + /** + * Of `changed`, the ids whose version here is an older one of another + * ref's, such as `origin/feat/x` behind `feat/x`, or a branch that a + * stacked branch builds on. They make no tag, proposal, or conflict. + */ + readonly older?: ReadonlySet; + /** Record ids the ref deleted since its merge base with HEAD. */ + readonly deleted?: ReadonlySet; + /** No merge base with HEAD was found, as in a shallow clone. */ + readonly noMergeBase?: true; + /** Changed records whose blob is not on disk (a partial clone). */ + readonly unavailable?: number; + readonly pr?: PullRequest; +} + +/** What `readRefSides` read, and how many refs `all` passed over. */ +export interface RefSet { + readonly sides: RefSide[]; + /** + * With `all`: refs looked at, and refs whose Proof folder is not one + * HEAD's history already had. + */ + readonly examined?: number; + readonly total?: number; +} + +/** Of `changed`, the ids whose version this ref adds to the overlay. */ +export function effectiveChanges(side: RefSide): string[] { + return [...side.changed.keys()].filter( + (id) => !side.sameAsBase.has(id) && !side.older?.has(id) + ); +} + +export interface OverlayConflict { + readonly effort: string | null; + readonly text: string; +} + +export interface Overlay { + /** Every record, base first, reverse supersession and invalidation rebuilt. */ + readonly records: readonly ReadRecord[]; + /** Ids some ref adds or changes away from the working tree. */ + readonly proposed: ReadonlySet; + /** `main`, `only on `, or `differs on : vs `. */ + tag(id: string): string; + readonly conflicts: readonly OverlayConflict[]; +} + +const FORWARD: Readonly> = { + supersedes: 'superseded_by', + invalidates: 'invalidated_by', +}; +const REVERSE = new Set(Object.values(FORWARD)); +const RELIES: readonly ReadRelation[] = ['derives_from', 'cites', 'evidence']; +const DEAD = new Set([ + 'superseded', + 'invalidated', + 'rejected', + 'deprecated', + 'retracted', +]); + +function ids(record: ReadRecord | undefined, relation: ReadRelation): string[] { + return [...(record?.relations[relation] ?? [])]; +} + +/** A record without its stored reverse supersession and invalidation. */ +function withoutReverse(record: ReadRecord): ReadRecord { + const relations = Object.fromEntries( + Object.entries(record.relations).filter(([key]) => !REVERSE.has(key)) + ); + return { ...record, relations }; +} + +function withReverse( + record: ReadRecord, + reverse: ReadonlyMap>> +): ReadRecord { + const base = withoutReverse(record); + const links = reverse.get(record.id); + if (!links) return base; + const relations = { ...base.relations }; + for (const [relation, sources] of links) + relations[relation] = [...sources].sort(); + return { ...base, relations }; +} + +/** Reverse supersession and invalidation over a set of records. */ +function reverseIndex( + records: Iterable +): Map>> { + const reverse = new Map>>(); + for (const record of records) + for (const [forward, back] of Object.entries(FORWARD)) + for (const target of ids(record, forward as ReadRelation)) { + if (!reverse.has(target)) reverse.set(target, new Map()); + const links = reverse.get(target) as Map>; + if (!links.has(back)) links.set(back, new Set()); + (links.get(back) as Set).add(record.id); + } + return reverse; +} + +function prLabel(side: RefSide): string { + if (!side.pr) return side.name; + return `${side.name} (${side.pr.isDraft ? 'draft' : 'PR'} #${ + side.pr.number + } by ${side.pr.author})`; +} + +function stored(record: ReadRecord): string { + const value = record.frontmatter.state ?? record.frontmatter.status; + return value === undefined ? '' : String(value); +} + +/** + * Builds the combined graph, the per-record tags, and the conflicts. A pure + * function of the base records and the refs, so tests need no git. + */ +export function buildOverlay( + baseRecords: readonly ReadRecord[], + sides: readonly RefSide[] +): Overlay { + const base = new Map(baseRecords.map((record) => [record.id, record])); + const differing = (side: RefSide, id: string) => + side.changed.has(id) && !side.sameAsBase.has(id) && !side.older?.has(id); + // Every version of each id: the base one, then each ref's changed one. + const versions = new Map(); + const addVersion = (side: string, record: ReadRecord) => { + if (!versions.has(record.id)) versions.set(record.id, []); + (versions.get(record.id) as { side: string; record: ReadRecord }[]).push({ + side, + record, + }); + }; + for (const record of baseRecords) addVersion(BASE, record); + for (const side of sides) + for (const [id, record] of side.changed) + if (differing(side, id)) addVersion(side.name, record); + + // The combined graph: the base version, else the first ref's, with the + // forward edges and `resolved_by` of every version. + const combined: ReadRecord[] = []; + for (const [, list] of versions) { + const chosen = withoutReverse(list[0].record); + const relations = { ...chosen.relations }; + for (const relation of [ + ...Object.keys(FORWARD), + 'resolved_by', + ] as ReadRelation[]) { + const all = new Set(list.flatMap(({ record }) => ids(record, relation))); + if (all.size) relations[relation] = [...all].sort(); + } + combined.push({ ...chosen, relations }); + } + const combinedReverse = reverseIndex(combined); + const records = combined.map((record) => + withReverse(record, combinedReverse) + ); + const byId = new Map(records.map((record) => [record.id, record])); + + // Each side's own view, for standings: the base, or the ref's tree with + // its changed versions and the base version of the rest. + const sideByName = new Map(sides.map((side) => [side.name, side])); + const views = new Map< + string, + { + get(id: string): ReadRecord | undefined; + reverse: Map>>; + } + >(); + const view = (name: string) => { + let found = views.get(name); + if (found) return found; + const side = sideByName.get(name); + const get = (id: string) => + !side + ? base.get(id) + : side.changed.get(id) ?? (side.ids.has(id) ? base.get(id) : undefined); + const members = side + ? [...side.ids].flatMap((id) => get(id) ?? []) + : [...base.values()]; + found = { get, reverse: reverseIndex(members) }; + views.set(name, found); + return found; + }; + const standingOn = (name: string, id: string): string | undefined => { + const { get, reverse } = view(name); + const record = get(id); + return record ? standingOf(withReverse(record, reverse)) : undefined; + }; + + const proposed = new Set(); + for (const [id, list] of versions) + if (list.some(({ side }) => side !== BASE)) proposed.add(id); + for (const side of sides) + for (const id of side.deleted ?? []) if (base.has(id)) proposed.add(id); + const tag = (id: string): string => { + if (base.has(id)) { + const parts = sides.flatMap((side) => { + if (side.deleted?.has(id)) return [`deleted on ${prLabel(side)}`]; + if (!differing(side, id)) return []; + const from = standingOn(BASE, id) ?? 'missing'; + const to = standingOn(side.name, id) ?? 'missing'; + return [ + `differs on ${prLabel(side)}: ${from} vs ${to}${ + from === to ? ', other fields differ' : '' + }`, + ]; + }); + return parts.length ? parts.join('; ') : 'main'; + } + const holders = sides.filter( + (side) => side.changed.has(id) && !side.older?.has(id) + ); + return holders.length + ? `only on ${holders.map(prLabel).join(', ')}` + : 'main'; + }; + + const effortOf = (id: string): string | null => { + const record = byId.get(id); + if (!record) return null; + if (record.kind === 'effort') return record.id; + return typeof record.frontmatter.effort === 'string' + ? record.frontmatter.effort + : null; + }; + const conflicts: OverlayConflict[] = []; + const conflict = (id: string, text: string) => + conflicts.push({ effort: effortOf(id), text }); + const sidesText = (list: readonly string[]) => [...new Set(list)].join(', '); + + // 1. Two accepted Decisions on one question Issue. A Decision counts when + // some side accepted it and it is not dead on the base. A ref that has it + // dead drops it only when the base accepted it: a ref that accepted one + // Decision and rejected the base's accepted one is a proposal, not a + // conflict. When the base never accepted it, one ref rejecting it does + // not outweigh another ref accepting it. A Decision is on a question when + // it derives from it, or when some side's version of the question lists + // it in `resolved_by`. + const accepted = new Map(); + for (const [id, list] of versions) { + if (byId.get(id)?.kind !== 'decision') continue; + const on = list + .filter(({ record }) => record.frontmatter.state === 'accepted') + .map(({ side }) => side); + const baseAccepted = on.includes(BASE); + const dead = list.some( + ({ side }) => + (side === BASE || baseAccepted) && DEAD.has(standingOn(side, id) ?? '') + ); + if (on.length && !dead) accepted.set(id, on); + } + const isQuestion = (id: string) => { + const issue = byId.get(id); + return issue?.kind === 'issue' && issue.frontmatter.kind === 'question'; + }; + const resolvedBy = new Map>(); + for (const [id, list] of versions) { + if (!isQuestion(id)) continue; + for (const { record } of list) + for (const decision of ids(record, 'resolved_by')) { + if (!resolvedBy.has(decision)) resolvedBy.set(decision, new Set()); + (resolvedBy.get(decision) as Set).add(id); + } + } + const byQuestion = new Map(); + for (const id of accepted.keys()) { + const questions = new Set([ + ...(versions.get(id) ?? []).flatMap(({ record }) => + ids(record, 'derives_from').filter(isQuestion) + ), + ...(resolvedBy.get(id) ?? []), + ]); + for (const question of questions) { + if (!byQuestion.has(question)) byQuestion.set(question, []); + (byQuestion.get(question) as string[]).push(id); + } + } + const answered = new Set(); + for (const [question, decisions] of [...byQuestion].sort(([a], [b]) => + a < b ? -1 : 1 + )) + if ( + decisions.length > 1 && + decisions.some((id) => + (accepted.get(id) ?? []).some((side) => side !== BASE) + ) + ) { + answered.add(question); + conflict( + question, + `two accepted Decisions on question ${question}: ${decisions + .sort() + .map((id) => `${id} (${sidesText(accepted.get(id) ?? [])})`) + .join(', ')}` + ); + } + + // Sides whose versions of `id` count against each other: every ref's, + // and the base's when the working tree changed it since some ref split + // off. A ref that changed a record the working tree left alone proposes. + const involvedVersions = (id: string) => { + const list = versions.get(id) ?? []; + const refs = list.filter(({ side }) => side !== BASE); + if (!refs.length) return []; + const baseVersion = list.find(({ side }) => side === BASE); + return baseVersion && + refs.some(({ side }) => sideByName.get(side)?.baseChanged.has(id)) + ? [baseVersion, ...refs] + : refs; + }; + + // 1b. One Issue resolved by different records on two sides, when rule 1 + // has not already named it. + for (const id of [...versions.keys()].sort()) { + if (answered.has(id) || byId.get(id)?.kind !== 'issue') continue; + const values = new Map(); + for (const { side, record } of involvedVersions(id)) { + const by = ids(record, 'resolved_by').sort().join(', '); + if (!by) continue; + if (!values.has(by)) values.set(by, []); + (values.get(by) as string[]).push(side); + } + if (values.size > 1) + conflict( + id, + `${id} resolved differently: ${[...values] + .map(([by, on]) => `by ${by} on ${sidesText(on)}`) + .join('; ')}` + ); + } + + // 2. One record superseded by two different records. Compactions are + // left to rule 5, since each one supersedes every head. + const superseders = new Map>(); + for (const [id, list] of versions) + for (const { side, record } of list) + for (const target of ids(record, 'supersedes')) { + if (!superseders.has(target)) superseders.set(target, new Map()); + const by = superseders.get(target) as Map; + if (!by.has(id)) by.set(id, []); + (by.get(id) as string[]).push(side); + } + for (const [target, by] of [...superseders].sort(([a], [b]) => + a < b ? -1 : 1 + )) { + const record = byId.get(target); + if (by.size < 2 || (record && isCompaction(record))) continue; + if (![...by.values()].some((list) => list.some((side) => side !== BASE))) + continue; + conflict( + target, + `${target} superseded by two records: ${[...by] + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map(([id, list]) => `${id} (${sidesText(list)})`) + .join(', ')}` + ); + } + + // 3. One id with a different stored state or status on two sides, where + // both sides changed it since they split. A ref that changed a record the + // working tree left alone is a proposal, shown on the card's tag. + for (const [id, list] of [...versions].sort(([a], [b]) => (a < b ? -1 : 1))) { + const involved = involvedVersions(id); + if (!involved.length) continue; + const values = new Map(); + for (const { side, record } of involved) { + const value = stored(record); + if (!values.has(value)) values.set(value, []); + (values.get(value) as string[]).push(side); + } + if (values.size < 2) continue; + const field = + list[0].record.frontmatter.state !== undefined ? 'state' : 'status'; + conflict( + id, + `${id} has different ${field}: ${[...values] + .map(([value, on]) => `${value || 'none'} on ${sidesText(on)}`) + .join('; ')}` + ); + } + + // 4. A retraction on one side of a record another side relies on, where + // the relying side changed or added the relying record since they split. + // Retract strips the retracted id from every record on its own side, so a + // reliance the base held before the split is already gone on the + // retracting side, and that side's version wins the merge. + const relyIndex = new Map< + string, + { side: string; id: string; field: string }[] + >(); + for (const [id, list] of versions) + for (const { side, record } of list) { + if (record.frontmatter.retracted === true) continue; + for (const field of RELIES) + for (const target of ids(record, field)) { + if (!relyIndex.has(target)) relyIndex.set(target, []); + ( + relyIndex.get(target) as { + side: string; + id: string; + field: string; + }[] + ).push({ side, id, field }); + } + } + for (const [id, list] of [...versions].sort(([a], [b]) => (a < b ? -1 : 1))) + for (const { side, record } of list) { + if (record.frontmatter.retracted !== true) continue; + for (const rely of relyIndex.get(id) ?? []) { + if (rely.side === side) continue; + // A ref's versions are all changed since its split. The base's + // count only when the working tree changed the relying record + // since the retracting ref split off. + if ( + rely.side === BASE && + !sideByName.get(side)?.baseChanged.has(rely.id) + ) + continue; + conflict( + id, + `${id} retracted on ${side}; ${rely.id} on ${rely.side} relies on it (${rely.field})` + ); + } + } + + // 5. Two head compactions for one Effort. + const heads = new Map(); + for (const record of records) { + if (!isCompaction(record) || record.frontmatter.retracted === true) + continue; + if (ids(record, 'superseded_by').length) continue; + const effort = effortOf(record.id) ?? ''; + if (!heads.has(effort)) heads.set(effort, []); + (heads.get(effort) as string[]).push(record.id); + } + const holders = (id: string) => + (versions.get(id) ?? []).map(({ side }) => side); + for (const [effort, list] of [...heads].sort(([a], [b]) => (a < b ? -1 : 1))) + if ( + list.length > 1 && + list.some((id) => holders(id).some((side) => side !== BASE)) + ) + conflicts.push({ + effort: effort || null, + text: `${effort} has ${list.length} head compactions: ${list + .sort() + .map((id) => `${id} (${sidesText(holders(id))})`) + .join(', ')}`, + }); + + return { records, proposed, tag, conflicts }; +} + +/** + * The conflicts in scope, for one Effort or all: the first CONFLICTS_SHOWN + * texts, and how many there are. + */ +export function conflictsInScope( + overlay: Overlay, + effort?: string +): { texts: string[]; total: number } { + const shown = overlay.conflicts.filter( + (conflict) => !effort || conflict.effort === effort + ); + return { + texts: shown.slice(0, CONFLICTS_SHOWN).map((conflict) => conflict.text), + total: shown.length, + }; +} + +// --------------------------------------------------------------------------- +// Reading refs with git. + +function invalid(message: string): never { + throw new ProofReadValidationError('PROOF_INVALID_ARGUMENT', message); +} + +function gitBytes( + cwd: string, + args: readonly string[], + input: string +): Promise<{ out: Buffer; ok: boolean }> { + return new Promise((resolveGit) => { + const child = execFile( + 'git', + [...NO_FETCH_ARGS, ...args], + { + cwd, + env: { ...process.env, ...NO_FETCH_ENV }, + maxBuffer: 1024 * 1024 * 1024, + encoding: 'buffer', + }, + // Keep what git printed before it stopped, such as cat-file dying at + // a blob a partial clone does not hold. + (error, stdout) => + resolveGit({ out: stdout ?? Buffer.alloc(0), ok: !error }) + ); + child.stdin?.end(input); + }); +} + +/** A map that drops its least recently used entry past `limit`. */ +class Lru { + private readonly map = new Map(); + constructor(private readonly limit: number) {} + has(key: string): boolean { + return this.map.has(key); + } + get(key: string): V | undefined { + if (!this.map.has(key)) return undefined; + const value = this.map.get(key) as V; + this.map.delete(key); + this.map.set(key, value); + return value; + } + set(key: string, value: V): void { + this.map.delete(key); + this.map.set(key, value); + if (this.map.size > this.limit) + this.map.delete(this.map.keys().next().value as string); + } +} + +/** The repository holding `rootDir`, and the Proof folder inside it. */ +export async function proofRepository( + rootDir: string +): Promise<{ top: string; root: string } | undefined> { + const top = (await git(rootDir, ['rev-parse', '--show-toplevel']))?.trim(); + if (!top) return undefined; + const real = await realpath(rootDir).catch(() => rootDir); + const realTop = await realpath(top).catch(() => top); + return { top: realTop, root: relative(realTop, real).split(sep).join('/') }; +} + +const KINDS: Readonly> = { + eff: 'effort', + iss: 'issue', + fnd: 'finding', + dec: 'decision', + con: 'constraint', + rsk: 'risk', + cit: 'citation', + blb: 'blob', +}; +const RELATION_FIELDS = new Set([ + 'derives_from', + 'supersedes', + 'superseded_by', + 'invalidates', + 'invalidated_by', + 'rejected_by', + 'mitigated_by', + 'resolved_by', + 'evidence', + 'cites', +]); + +/** A record file's bytes as the `find` index reads one. */ +export function recordFromFile( + path: string, + bytes: Buffer | string +): ReadRecord | undefined { + let parsed; + try { + parsed = parseDocument(bytes); + } catch { + return undefined; + } + const { frontmatter: data, body } = parsed; + const id = typeof data.id === 'string' ? data.id : undefined; + const kind = id ? KINDS[id.split('-', 1)[0]] : undefined; + if (!id || !kind) return undefined; + const frontmatter: Record = {}; + const relations: Partial> = {}; + for (const [key, value] of Object.entries(data)) { + if (key === 'id' || value === undefined) continue; + if (RELATION_FIELDS.has(key)) + relations[key as ReadRelation] = ( + Array.isArray(value) ? value : [value] + ).filter((item): item is string => typeof item === 'string'); + else frontmatter[key] = value instanceof Date ? value.toISOString() : value; + } + return { id, kind, path, frontmatter, body_excerpt: body, relations }; +} + +function isRecordPath(path: string): boolean { + return path.endsWith('.md') && !path.includes('/.journal/'); +} + +/** `git ls-tree` of the Proof folder: record path to blob sha. */ +const treeCache = new Lru>(512); +async function proofTree( + top: string, + root: string, + commit: string +): Promise> { + const key = `${top}\0${root}\0${commit}`; + const cached = treeCache.get(key); + if (cached) return cached; + const out = await git(top, [ + 'ls-tree', + '-r', + '-z', + commit, + '--', + root || '.', + ]); + const tree = new Map(); + for (const entry of (out ?? '').split('\0')) { + const tab = entry.indexOf('\t'); + if (tab < 0) continue; + const [, type, sha] = entry.slice(0, tab).split(' '); + const path = entry.slice(tab + 1); + if (type !== 'blob' || !isRecordPath(path)) continue; + tree.set(path, sha); + } + treeCache.set(key, tree); + return tree; +} + +/** `git merge-base`, cached; a pair of commits always has the same one. */ +const mergeBaseCache = new Lru(4096); +async function mergeBase( + top: string, + a: string, + b: string +): Promise { + const key = `${top}\0${a}\0${b}`; + if (!mergeBaseCache.has(key)) + mergeBaseCache.set( + key, + (await git(top, ['merge-base', a, b]))?.trim() || null + ); + return mergeBaseCache.get(key) ?? undefined; +} + +/** + * Every blob sha a record file held in HEAD's history, so a ref version + * that main already took, as by a squash merge, counts as merged; and every + * tree the Proof folder had there, so a ref whose Proof folder is one of + * them adds nothing and needs no closer look. + */ +const historyCache = new Lru<{ blobs: Set; trees: Set }>(8); +async function headHistory( + top: string, + root: string, + head: string +): Promise<{ blobs: Set; trees: Set }> { + const key = `${top}\0${root}\0${head}`; + const cached = historyCache.get(key); + if (cached) return cached; + const blobs = new Set((await proofTree(top, root, head)).values()); + const trees = new Set(); + const out = await git(top, [ + 'log', + '--raw', + '-t', + '--no-renames', + '--no-abbrev', + `--format=${root ? '' : '%T'}`, + head, + '--', + root || '.', + ]); + for (const line of (out ?? '').split('\n')) { + const tab = line.indexOf('\t'); + if (!line.startsWith(':') || tab < 0) { + if (!root && /^[0-9a-f]{40,64}$/.test(line.trim())) + trees.add(line.trim()); + continue; + } + const path = line.slice(tab + 1); + const shas = line + .slice(1, tab) + .split(' ') + .slice(2, 4) + .filter((sha) => !/^0+$/.test(sha)); + for (const sha of shas) + if (isRecordPath(path)) blobs.add(sha); + else if (path === root) trees.add(sha); + } + const found = { blobs, trees }; + historyCache.set(key, found); + return found; +} + +/** The Proof folder's tree at each commit, in one `cat-file` call. */ +async function proofTreeOids( + top: string, + root: string, + commits: readonly string[] +): Promise> { + const out = await git( + top, + ['cat-file', '--batch-check=%(objectname) %(objecttype)'], + `${commits + .map((commit) => (root ? `${commit}:${root}` : `${commit}^{tree}`)) + .join('\n')}\n` + ); + const oids = new Map(); + (out ?? '').split('\n').forEach((line, i) => { + const [oid, type] = line.split(' '); + if (type === 'tree' && i < commits.length) oids.set(commits[i], oid); + }); + return oids; +} + +/** Whether the repository is a partial clone, whose blobs may be missing. */ +const partialCache = new Map>(); +function isPartialClone(top: string): Promise { + let found = partialCache.get(top); + if (!found) { + found = Promise.all([ + git(top, ['config', '--get', 'extensions.partialClone']), + git(top, ['config', '--get-regexp', '^remote\\..*\\.promisor$']), + ]).then( + ([extension, promisor]) => + Boolean(extension?.trim()) || /\strue$/m.test(promisor ?? '') + ); + partialCache.set(top, found); + } + return found; +} + +/** + * Blobs `commit` holds that `exclude` does not and that are not on disk. + * `rev-list --missing=print` lists them without fetching. + */ +async function missingBlobs( + top: string, + commit: string, + exclude: string | undefined +): Promise> { + const out = await git(top, [ + 'rev-list', + '--objects', + '--missing=print', + commit, + ...(exclude ? [`^${exclude}`] : []), + ]); + return new Set( + (out ?? '') + .split('\n') + .filter((line) => line.startsWith('?')) + .map((line) => line.slice(1).trim()) + ); +} + +/** Parsed records by blob sha; a blob never changes. */ +const blobCache = new Lru(20_000); +async function readBlobs( + top: string, + wanted: ReadonlyMap, + absent: ReadonlySet +): Promise<{ records: Map; unavailable: number }> { + const missing = [...new Set(wanted.values())].filter( + (sha) => !blobCache.has(sha) && !absent.has(sha) + ); + const read = new Set(); + if (missing.length) { + const { out } = await gitBytes( + top, + ['cat-file', '--batch'], + `${missing.join('\n')}\n` + ); + const paths = new Map([...wanted].map(([path, sha]) => [sha, path])); + let at = 0; + while (at < out.length) { + const newline = out.indexOf(10, at); + if (newline < 0) break; + const [sha, type, size] = out.subarray(at, newline).toString().split(' '); + if (type === 'missing' || size === undefined) { + at = newline + 1; + continue; + } + const length = Number(size); + if (newline + 1 + length > out.length) break; + const bytes = out.subarray(newline + 1, newline + 1 + length); + at = newline + 1 + length + 1; + read.add(sha); + blobCache.set( + sha, + recordFromFile(paths.get(sha) ?? '', Buffer.from(bytes)) ?? null + ); + } + } + const records = new Map(); + let unavailable = 0; + for (const [path, sha] of wanted) { + if (!blobCache.has(sha)) { + unavailable++; + continue; + } + const record = blobCache.get(sha); + if (record) records.set(path, { ...record, path }); + } + return { records, unavailable }; +} + +function blobSha(bytes: Buffer): string { + return createHash('sha1') + .update(`blob ${bytes.length}\0`) + .update(bytes) + .digest('hex'); +} + +function idOf(path: string): string { + return path.slice(path.lastIndexOf('/') + 1, -'.md'.length); +} + +/** Whether a ref changes anything, counting records not on disk. */ +function addsAnything(side: RefSide): boolean { + return Boolean( + effectiveChanges(side).length || side.deleted?.size || side.unavailable + ); +} + +/** A side as `readSide` builds it, with what the pairwise check needs. */ +interface ReadSide extends RefSide { + readonly blobs: ReadonlyMap; + readonly older: Set; +} + +interface RepoContext { + readonly top: string; + readonly root: string; + readonly head?: string; + readonly prs: readonly PullRequest[]; + readonly remotes: readonly string[]; + readonly partial: boolean; + /** Record paths the working tree has that git does not track. */ + readonly untracked: () => Promise>; +} + +/** + * The pull request for a ref: one whose head branch is the ref's branch, + * else one whose head branch is the ref with a remote name in front. + */ +export function matchPullRequest( + name: string, + prs: readonly PullRequest[], + remotes: readonly string[] +): PullRequest | undefined { + const branch = name.replace(/^refs\/(?:heads|remotes)\//, ''); + return ( + prs.find((candidate) => candidate.headRefName === branch) ?? + prs.find((candidate) => + remotes.some((remote) => branch === `${remote}/${candidate.headRefName}`) + ) + ); +} + +/** What one ref changed in the Proof folder since its merge base with HEAD. */ +async function readSide( + repo: RepoContext, + name: string, + sha: string +): Promise { + const { top, root, head } = repo; + const tree = await proofTree(top, root, sha); + const split = head ? await mergeBase(top, head, sha) : undefined; + // With no merge base, as in a shallow clone, HEAD stands in for it, so + // only working-tree edits count as the base's changes. + const since = split ?? head; + const before = since + ? await proofTree(top, root, since) + : new Map(); + const merged = head + ? (await headHistory(top, root, head)).blobs + : new Set(); + const changedPaths = new Map( + [...tree].filter( + ([path, blob]) => before.get(path) !== blob && !merged.has(blob) + ) + ); + // A deletion needs a merge base: against HEAD, every record main added + // after the ref split off would read as deleted. + const deleted = new Set( + split ? [...before.keys()].filter((path) => !tree.has(path)).map(idOf) : [] + ); + const pr = matchPullRequest(name, repo.prs, repo.remotes); + const empty = { + name, + sha, + ids: new Set([...tree.keys()].map(idOf)), + deleted, + ...(head && !split ? { noMergeBase: true as const } : {}), + ...(pr ? { pr } : {}), + }; + if (!changedPaths.size && !deleted.size) + return { + ...empty, + changed: new Map(), + baseChanged: new Set(), + sameAsBase: new Set(), + blobs: new Map(), + older: new Set(), + }; + const absent = repo.partial + ? await missingBlobs(top, sha, since) + : new Set(); + const { records: parsed, unavailable } = await readBlobs( + top, + changedPaths, + absent + ); + const changed = new Map(); + const blobs = new Map(); + const sameAsBase = new Set(); + for (const [path, record] of parsed) { + changed.set(record.id, record); + blobs.set(record.id, { path, sha: changedPaths.get(path) as string }); + const bytes = await readFile(join(top, path)).catch(() => undefined); + if (bytes && blobSha(bytes) === changedPaths.get(path)) + sameAsBase.add(record.id); + } + // What the working tree changed or added since the split: tracked edits + // against that commit, and record files git does not track yet. + const baseChanged = new Set(); + if (since) { + const out = await git(top, [ + 'diff', + '--name-only', + '--no-renames', + '-z', + since, + '--', + root || '.', + ]); + for (const path of (out ?? '').split('\0')) + if (isRecordPath(path)) baseChanged.add(idOf(path)); + } + for (const path of await repo.untracked()) baseChanged.add(idOf(path)); + return { + ...empty, + changed, + baseChanged, + sameAsBase, + blobs, + older: new Set(), + ...(unavailable ? { unavailable } : {}), + }; +} + +/** + * Marks versions that are older ones of another ref's. When two refs hold + * different versions of a record, and one of them is the version at the two + * refs' own merge base, the other ref changed it later: a local branch ahead + * of its remote-tracking ref, or a branch stacked on another. + */ +async function markOlder(repo: RepoContext, sides: readonly ReadSide[]) { + for (let i = 0; i < sides.length; i++) + for (let j = i + 1; j < sides.length; j++) { + const [a, b] = [sides[i], sides[j]]; + const shared = [...a.blobs].filter( + ([id, blob]) => b.blobs.has(id) && b.blobs.get(id)?.sha !== blob.sha + ); + if (!shared.length) continue; + const split = await mergeBase(repo.top, a.sha, b.sha); + if (!split) continue; + const tree = await proofTree(repo.top, repo.root, split); + for (const [id, blob] of shared) { + const at = tree.get(blob.path); + if (at === blob.sha) a.older.add(id); + else if (at === b.blobs.get(id)?.sha) b.older.add(id); + } + } +} + +/** + * For `all`: of a local branch and its remote-tracking ref (`feat/x` and + * `origin/feat/x`), the one the other contains, since it shows nothing the + * other does not. + */ +async function trackingPairs( + repo: RepoContext, + sides: readonly ReadSide[] +): Promise> { + const byName = new Map(sides.map((side) => [side.name, side])); + const isAncestor = async (a: string, b: string) => + (await git(repo.top, ['merge-base', '--is-ancestor', a, b])) !== undefined; + const redundant = new Set(); + for (const side of sides) { + const remote = repo.remotes.find((name) => + side.name.startsWith(`${name}/`) + ); + const local = remote && byName.get(side.name.slice(remote.length + 1)); + if (!local) continue; + if (await isAncestor(side.sha, local.sha)) redundant.add(side); + else if (await isAncestor(local.sha, side.sha)) redundant.add(local); + } + return redundant; +} + +/** Pull requests from a gitignored `.flatbread/proof/prs.json`, if any. */ +export async function readPullRequests( + cacheRoot: string +): Promise { + try { + const data = JSON.parse( + await readFile(join(cacheRoot, 'prs.json'), 'utf8') + ); + if (!Array.isArray(data)) return []; + return data.flatMap((entry) => { + if (!entry || typeof entry !== 'object') return []; + const author = + typeof entry.author === 'string' + ? entry.author + : typeof entry.author?.login === 'string' + ? entry.author.login + : 'unknown'; + return typeof entry.number === 'number' && + typeof entry.headRefName === 'string' + ? [ + { + number: entry.number, + headRefName: entry.headRefName, + isDraft: entry.isDraft === true, + author, + }, + ] + : []; + }); + } catch { + return []; + } +} + +/** + * The branches `all` looks at: local and remote, newest commit first, one + * name per commit, leaving out HEAD's commit and symbolic refs. + */ +export async function branchCandidates( + top: string, + head: string | undefined +): Promise<{ short: string; sha: string }[]> { + const out = await git(top, [ + 'for-each-ref', + '--sort=-committerdate', + '--format=%(refname)%00%(objectname)%00%(refname:short)%00%(symref)', + 'refs/heads', + 'refs/remotes', + ]); + const seen = new Set(head ? [head] : []); + const candidates: { short: string; sha: string }[] = []; + for (const line of (out ?? '').split('\n')) { + const [, sha, short, symref] = line.split('\0'); + if (!sha || symref || seen.has(sha)) continue; + seen.add(sha); + candidates.push({ short, sha }); + } + return candidates; +} + +/** + * What `path` is in each commit's tree: `file`, `dir`, or left out when the + * tree does not have it. One `cat-file --batch-check` call, the batch form + * of `git cat-file -e :`; it reads trees only, never blobs. + */ +export async function pathInTrees( + top: string, + commits: readonly string[], + path: string +): Promise> { + const found = new Map(); + if (!commits.length || !path || /[\n\0]/.test(path)) return found; + const out = await git( + top, + ['cat-file', '--batch-check=%(objecttype)'], + `${commits.map((commit) => `${commit}:${path}`).join('\n')}\n` + ); + (out ?? '').split('\n').forEach((line, i) => { + if (i >= commits.length) return; + const type = line.trim(); + if (type === 'blob') found.set(commits[i], 'file'); + else if (type === 'tree') found.set(commits[i], 'dir'); + }); + return found; +} + +/** + * Reads the refs to overlay. `all` skips local and remote branches whose + * Proof folder HEAD's history already had, looks at the REFS_ALL_EXAMINED + * others with the newest commits, and keeps those that change a Proof + * record since their merge base with HEAD, at most REFS_ALL_LIMIT. + * One commit reached by two names is read once, under the first name. + */ +export async function readRefSides( + rootDir: string, + cacheRoot: string, + refs: readonly string[] | 'all' +): Promise { + const found = await proofRepository(rootDir); + if (!found) invalid('--refs needs the Proof root inside a git repository'); + const { top, root } = found; + const head = ( + await git(top, ['rev-parse', '--verify', '-q', 'HEAD']) + )?.trim(); + let untracked: Promise> | undefined; + const repo: RepoContext = { + top, + root, + ...(head ? { head } : {}), + prs: await readPullRequests(cacheRoot), + remotes: ((await git(top, ['remote'])) ?? '').split('\n').filter(Boolean), + partial: await isPartialClone(top), + untracked: () => + (untracked ??= git(top, [ + 'ls-files', + '--others', + '--exclude-standard', + '-z', + '--', + root || '.', + ]).then((out) => new Set((out ?? '').split('\0').filter(isRecordPath)))), + }; + const sides: ReadSide[] = []; + if (refs === 'all') { + const candidates = await branchCandidates(top, head); + // A ref whose Proof folder is one HEAD's history already had adds + // nothing, and costs no more git calls than this one. + const oids = await proofTreeOids( + top, + root, + candidates.map(({ sha }) => sha) + ); + const known = head + ? (await headHistory(top, root, head)).trees + : new Set(); + const touching = candidates.filter(({ sha }) => { + const oid = oids.get(sha); + return oid !== undefined && !known.has(oid); + }); + const examined = touching.slice(0, REFS_ALL_EXAMINED); + for (const { short, sha } of examined) { + const side = await readSide(repo, short, sha); + if (addsAnything(side)) sides.push(side); + if (sides.length >= REFS_ALL_LIMIT) break; + } + await markOlder(repo, sides); + const redundant = await trackingPairs(repo, sides); + return { + sides: sides.filter((side) => !redundant.has(side) && addsAnything(side)), + examined: examined.length, + total: touching.length, + }; + } + for (const name of refs) { + const sha = ( + await git(top, ['rev-parse', '--verify', '-q', `${name}^{commit}`]) + )?.trim(); + if (!sha) invalid(`Unknown git ref: ${name}`); + sides.push(await readSide(repo, name, sha)); + } + await markOlder(repo, sides); + return { sides }; +} + +/** + * What a ref changes, as the refs line and `proof branches` count it: + * records a card can show (every kind but Effort) that it adds, changes, or + * deletes, and Efforts apart. + */ +export function changeCounts(side: RefSide): { + records: number; + efforts: number; +} { + const changed = effectiveChanges(side); + const efforts = changed.filter( + (id) => side.changed.get(id)?.kind === 'effort' + ).length; + return { + records: changed.length - efforts + (side.deleted?.size ?? 0), + efforts, + }; +} + +/** The refs line a card set prints after its header. */ +export function refsLine(set: RefSet): string { + const examined = + set.total !== undefined && set.examined !== undefined + ? set.total > set.examined + ? `; examined the newest ${set.examined} of ${set.total} refs that change the Proof folder` + : '' + : ''; + if (!set.sides.length) return `refs: none change Proof records${examined}`; + const shallow = set.sides.some((side) => side.noMergeBase); + return `refs: ${set.sides + .map((side) => { + const { records, efforts } = changeCounts(side); + const notes = [ + `${records} changed`, + ...(side.deleted?.size ? [`${side.deleted.size} deleted`] : []), + ...(efforts ? [`${efforts} Effort${efforts > 1 ? 's' : ''}`] : []), + ...(side.unavailable + ? [ + `${side.unavailable} record${ + side.unavailable > 1 ? 's' : '' + } not available locally`, + ] + : []), + ...(side.noMergeBase ? ['no merge base: shallow clone?'] : []), + ]; + return `${prLabel(side)} ${side.sha.slice(0, 7)} (${notes.join(', ')})`; + }) + .join(', ')}${examined}${ + shallow + ? '; with no merge base, a ref counts every record that differs from HEAD; git fetch --deepen=200 finds one' + : '' + }`; +} + +/** + * Writes `.flatbread/proof/prs.json` from `gh pr list`, the only networked + * step. Fails with a plain message when gh is missing or not signed in. + */ +export async function refreshPullRequests( + rootDir: string, + cacheRoot: string +): Promise<{ path: string; count: number; ignored: boolean }> { + const repo = await proofRepository(rootDir); + const cwd = repo?.top ?? rootDir; + const result = await new Promise<{ out?: string; error?: string }>( + (resolveGh) => + execFile( + 'gh', + [ + 'pr', + 'list', + '--state', + 'open', + '--limit', + '200', + '--json', + 'number,headRefName,isDraft,author', + ], + { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, + (error, stdout, stderr) => + resolveGh( + error + ? { + error: + (error as NodeJS.ErrnoException).code === 'ENOENT' + ? 'gh is not installed. Install the GitHub CLI (https://cli.github.com) or write .flatbread/proof/prs.json by hand.' + : `gh pr list failed: ${ + String(stderr).trim() || error.message + }. Run gh auth login if gh is not signed in.`, + } + : { out: stdout } + ) + ) + ); + // The CLI prints an Error whose message is JSON as that JSON. + const ghError = (message: string) => + new Error( + JSON.stringify({ error: { code: 'PROOF_GH_UNAVAILABLE', message } }) + ); + if (result.error) throw ghError(result.error); + let list: unknown; + try { + list = JSON.parse(result.out ?? ''); + } catch { + throw ghError('gh pr list did not print JSON'); + } + const prs = (Array.isArray(list) ? list : []).map( + (entry: Record) => ({ + number: entry.number, + headRefName: entry.headRefName, + isDraft: entry.isDraft === true, + author: + typeof entry.author === 'object' && entry.author + ? (entry.author as { login?: string }).login ?? 'unknown' + : String(entry.author ?? 'unknown'), + }) + ); + const path = join(cacheRoot, 'prs.json'); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(prs, null, 2)}\n`); + const ignored = + repo !== undefined && + (await git(repo.top, ['check-ignore', '-q', path])) !== undefined; + return { path, count: prs.length, ignored }; +} diff --git a/packages/proof/skills/proof/SKILL.md b/packages/proof/skills/proof/SKILL.md index 18ba10ed..c8a105b3 100644 --- a/packages/proof/skills/proof/SKILL.md +++ b/packages/proof/skills/proof/SKILL.md @@ -129,7 +129,9 @@ Critical semantics: `superseded_by` and `invalidated_by` reverse projections; `derives_from` writes no reverse edge. - External sources: create a `WriteCitation` record (its body may be a URL, - with optional `blob` and `role` fields), then add + with optional `blob` and `role` fields; `role: code` with a body such as + `src/a.ts#symbol@sha` ties the citing records to that code for + `proof find --path`), then add `cites: [""]` when creating an Issue, Finding, Decision, Constraint, or Risk. Both the Citation in `cites` and the Blob in `Citation.blob` must belong to the same Effort as the record that links to @@ -194,7 +196,23 @@ Pick the read by what you need, in this order: 3. **Read one record in full: `proof get `.** On a compaction, `get` also says whether it is stale, and why, gives the exact number of records that changed since it was written, and lists up to 25 of them. -4. **Page through records: `proof records`.** `find` shows only the top +4. **Before opening code: `proof find --path `.** It + lists the records that govern that code, each at its chain head, with a + `via:` line naming the tie: a code Citation (`role: code`, body + `src/a.ts#symbol@sha`) the record cites, a commit touching the path with + a `Proof: ` trailer, or a body that names the path. Add words to + rank them. Run it before you change a file, so you build on the accepted + Decisions and Constraints instead of crawling the codebase or the graph. +5. **Before building on a Decision: `proof find --refs all`.** It overlays + the Proof records other branches change, without a checkout, tags each + card `main`, `only on `, or `differs on : vs ` + (with the draft PR when `proof branches --refresh-prs` has run), and ends + with a `CONFLICTS` block: two accepted Decisions on one question, one + Issue resolved two ways, a record superseded twice, a state two branches changed differently, a + retraction of something another branch relies on, two head compactions. + Without words it lists what the branches propose. It composes with + `--path` and `--effort`. +6. **Page through records: `proof records`.** `find` shows only the top hits, so never count from it; count from `state`. `--state` and `--status` match only the stored field. `--standing invalidated` (or `superseded`, `retracted`, any standing a card prints) lists records by @@ -213,6 +231,14 @@ byte cap). Zoom in with `get`, then Read/grep that digest — do not open flatbread proof find "tern history buffer precision" --effort flatbread proof find "shadow pool overflow" --as-of 2026-03-01T00:00:00Z +# The records that govern a file, folder, or symbol, before you open it +flatbread proof find --path src/render/shadows.ts +flatbread proof find "pool size" --path src/render/shadows.ts#allocate + +# What other branches propose, and where they conflict, without a checkout +flatbread proof find --refs all --effort +flatbread proof find "shadow pool" --refs feat/pool-v2,origin/feat/atlas + # Counts, open blockers, Risks, contested topics, dead evidence, lifecycles flatbread proof state @@ -273,7 +299,10 @@ server-side. `dec-adopt-a-bounded-status-briefing-fast-path-for-ef--kcw0rw39g3b2ym2h`). 2. **When you need a particular record or fact:** run `proof find` first, then `proof get` the hit whose card fits. Do not page through `records` - to look for it. **When you need a count, a list of open, closed, or + to look for it. **When you are about to change code:** run + `proof find --path ` first. **When you are about to build on a + Decision, or accept one:** run `proof find --refs all` (scoped with + `--effort`) and read its `CONFLICTS` block. **When you need a count, a list of open, closed, or rejected records, a status history, the blockers, or what rests on dead evidence:** run `proof state ` instead. 3. **When a browse digest shows `[…truncated]` and you need the body:** run diff --git a/packages/proof/skills/proof/reference.md b/packages/proof/skills/proof/reference.md index bf981882..81ec9a8b 100644 --- a/packages/proof/skills/proof/reference.md +++ b/packages/proof/skills/proof/reference.md @@ -254,6 +254,9 @@ flatbread proof relations --relations r1,r2 [--limit n] [--c flatbread proof blocking-decisions [consistency flags] flatbread proof brief [consistency flags] flatbread proof find "" [--effort | --all] [--kinds k1,k2] [--as-of iso] [--retracted exclude|include|only] [--limit n] [consistency flags] +flatbread proof find [""] --path [--refs r1,r2|all] [find flags] +flatbread proof find [""] --refs [find flags] +flatbread proof branches [--refresh-prs] flatbread proof state [consistency flags] flatbread proof cache prune flatbread proof serve [--socket .flatbread/proof/serve.sock] @@ -420,9 +423,123 @@ Each card has these lines: ``` The card header reads `find "" in ( records searched, matched, shown)`, then `matched: … | missing: …`. The envelope adds `cards` (that Markdown) and `find`: -`{"query","effort_id","searched","matched","shown","terms":{"matched","missing"},"efforts"?,"hits":[{"id","matched_id"?,"score"}]}`. +`{"query","effort_id","searched","matched","shown","terms":{"matched","missing"},"efforts"?,"hits":[{"id","matched_id"?,"score","via"?,"branch"?}],"path"?,"refs"?,"conflicts"?,"conflicts_total"?}`. `find` shows only the top hits; use `state` or `records` to count. +### `proof find --path`: the records that govern a piece of code + +`--path ` returns the records that govern that code, without +reading the code or the whole graph. Words are optional. The target is a +file or folder (relative to the current folder when it exists there, else +to the repository root), a symbol (`startFrame`, `startFrame()`, +`Planner.plan`), or both (`src/fuse.ts#budget`). A folder matches the files +under it. The repository root itself (`.`) is refused. Three sources tie a record to code, strongest first: + +1. **Code Citations:** a Citation with `role: code` whose body names the + code as ``, `#`, or `#@`. The + records that cite it govern the code, and so do the records it + `derives_from`; a code Citation nothing cites stands for itself. A file + anchor with no symbol covers every symbol in the file. Write one when a + Decision or Constraint is about specific code: + `{"type":"WriteCitation","effort":"","title":"Fuse budget","body":"src/fuse.ts#budget@1a2b3c4","role":"code"}`, + then put its id in the record's `cites`. +2. **Commit trailers:** commits that touch the path and carry one or more + `Proof: ` trailers (`git log` from the Proof root's repository; + skipped quietly outside git, and for a bare symbol). +3. **Body text:** a record body that names the path as a whole token + (`src/a.ts` does not match `src/a.tsx`), or the symbol as `name(`, + `obj.name(`, or `#name`. + +Each governed record resolves to its chain head, as `find` shows chains; +retracted records follow `--retracted`. With words, BM25 ranks the +governed set (records no word matches still list, after the matches). +Without words, the strongest tie ranks them, then the newest; the header +drops `score` and the `matched:` line. Default 5 cards, `--limit` up to 10. +Each card adds, after its title: + +```text + via: cites : src/fuse.ts#budget@1a2b3c4; commit 1a2b3c4 "" (on ); body names src/fuse.ts +``` + +The header reads `find [""] --path in ( governing, shown)`. + +### `proof find --refs`: what other branches propose + +`--refs feat/a,origin/feat/b` or `--refs all` overlays records from other +git refs without checking them out (`git ls-tree` and `git cat-file`, +cached by commit). No git command it runs fetches: in a partial clone, a +record whose blob is not on disk is left out, and the `refs:` line says +`N records not available locally`. `all` skips local and remote branches +whose Proof folder HEAD's history already had, looks at the 50 others with +the newest commits (the `refs:` line says so when there are more), and +keeps those that change a Proof record, at most 12. Of a local branch and +its remote-tracking ref, `all` keeps only the newer one when one contains +the other. A ref counts only for the records it changed since its merge +base with HEAD, so a branch that is merely behind adds nothing, and a +version already in HEAD's history does not count, so a branch left behind +after a squash merge adds nothing. When two refs hold different versions +of a record and one is the version at the two refs' own merge base, as +with a branch stacked on another, the older version is dropped. With no +merge base (a shallow clone), HEAD stands in for it, the `refs:` line says +`no merge base: shallow clone?`, and `git fetch --deepen=200` finds one. +The base is the working tree. In the combined graph, supersession and invalidation come +from the forward edges (`supersedes`, `invalidates`) of every version; the +stored `superseded_by` and `invalidated_by` on either side are ignored. An +Issue's `resolved_by` is the union over every version, so a Decision that +resolves a question only on a branch still shows `in: resolves`. With +words, `find` searches the combined graph; without words or `--path`, the +cards are the records the refs add or change, newest first. Each card adds +a `branch:` line: + +```text + branch: main (same as the working tree) + branch: only on feat/a (draft #12 by ana) (draft PR from prs.json) + branch: differs on feat/a: proposed vs accepted ( vs ) + branch: deleted on feat/a (the ref removed the record file) + branch: only on feat/b; rival heads: (only on feat/a) +``` + +The last form is a chain that two branches each supersede: the chain line +marks every head `(head)`, and no head replaces the other. + +A `refs:` line after the header names each ref, its commit, and how many +records it adds, changes, or deletes (Efforts counted apart). The cards end +with `CONFLICTS ()` and one `- ` line each (at most 12, then +`+n more`), or `CONFLICTS: none`. With `--effort`, +only that Effort's conflicts show. A conflict names its refs (`base` is the +working tree) and ids: + +- two accepted Decisions on one question Issue, tied to it by + `derives_from` or by the Issue's `resolved_by` on any side (a ref that + accepted one and rejected the base's accepted one is a proposal, not a + conflict); +- one Issue resolved by different records on two sides, when the line + above does not already name it; +- one record superseded by two different records; +- one id with a different stored `state` or `status` on two sides that + both changed it since they split (a change only one ref made shows on its + card's `branch:` line instead); +- a retraction on one side of a record another side relies on + (`derives_from`, `cites`, `evidence`), when that side added or changed + the relying record since they split; +- two head compactions for one Effort. + +`--refs` composes with `--path`, `--effort`, and `--all`; with `--path`, +commit trailers on the refs count too. The envelope's `find` adds `refs` +(`[{"name","sha","changed","pr"?}]`), `conflicts` (the same 12 the cards +list), and `conflicts_total`. Narrow with `--effort` or fewer refs to see +the rest. + +`proof branches` lists the refs `--refs all` would overlay, with their +pull requests. Pull requests come from a gitignored +`.flatbread/proof/prs.json` (an array of +`{number, headRefName, isDraft, author}`). A ref matches a pull request +whose `headRefName` is its branch, or is its branch with a remote name in +front (`origin/feat/a`). `proof branches --refresh-prs` +rewrites it from `gh pr list --json number,headRefName,isDraft,author`, the +only step that uses the network; it fails with a plain message when `gh` is +missing or not signed in, and warns when the file is not gitignored. + ### `proof state` `state` shows where one Effort stands, in one read of at most about 4,000