From 25a09368401c8c88d587d7c82b2711546adeadbb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 07:35:45 +0000 Subject: [PATCH] chore: sync from vibgrate-cli monorepo (v2026.1003.1) Outbound mirror of packages/vibgrate-cli-public. Passed the public-surface leak gate. --- DOCS.md | 361 +++++++- README.md | 9 +- action.yml | 2 +- charts/vibgrate/Chart.yaml | 4 +- package.json | 2 +- packaging/homebrew-tap/Formula/vg.rb | 4 +- packaging/scoop-bucket/vg.json | 2 +- releases/v2026.1003.1.md | 53 ++ src/commands/path.ts | 33 +- src/commands/review.ts | 405 +++++++- src/commands/serve.ts | 4 +- src/commands/show.ts | 23 +- src/commands/why.ts | 82 +- src/engine/artifacts.ts | 4 + src/engine/cache.ts | 9 +- src/engine/cas.ts | 3 +- src/engine/edge-sites.ts | 25 + src/engine/haile/haile-provider.ts | 14 + src/engine/parse.ts | 21 + src/engine/paths.ts | 114 ++- src/engine/resolve.ts | 12 +- src/engine/scip.ts | 10 +- src/engine/ts-resolver.ts | 26 +- src/engine/tsc-cache.ts | 3 +- src/engine/types.ts | 2 + src/mcp/review-tools.test.ts | 152 +++ src/mcp/review-tools.ts | 180 ++++ src/mcp/server.ts | 6 +- src/mcp/tools.ts | 17 +- src/reporting/commands/init.test.ts | 55 ++ src/reporting/commands/init.ts | 4 +- src/review/base-graph.test.ts | 71 ++ src/review/base-graph.ts | 50 + src/review/data-models.test.ts | 175 ++++ src/review/data-models.ts | 481 ++++++++++ src/review/derive.test.ts | 223 +++++ src/review/derive.ts | 248 +++++ src/review/doc-build.ts | 162 ++++ src/review/doc-comments.test.ts | 44 + src/review/doc-comments.ts | 110 +++ src/review/doc-store.test.ts | 290 ++++++ src/review/doc-store.ts | 616 ++++++++++++ src/review/doc.test.ts | 407 ++++++++ src/review/doc.ts | 1287 ++++++++++++++++++++++++++ src/review/explain-doc.test.ts | 144 +++ src/review/explain-doc.ts | 169 ++++ src/review/git.ts | 25 +- src/review/groups.test.ts | 246 +++++ src/review/groups.ts | 465 ++++++++++ src/review/multi-repo.test.ts | 115 +++ src/review/multi-repo.ts | 138 +++ src/review/provenance-cloud.test.ts | 99 ++ src/review/provenance-cloud.ts | 156 ++++ src/review/provenance.test.ts | 119 +++ src/review/provenance.ts | 153 +++ src/review/push.test.ts | 34 +- src/review/push.ts | 65 +- src/review/session.test.ts | 167 ++++ src/review/session.ts | 216 +++++ src/schema.ts | 12 + src/version.ts | 2 +- test/edge-sites.test.ts | 93 ++ test/path-calls.test.ts | 64 ++ 63 files changed, 8223 insertions(+), 64 deletions(-) create mode 100644 releases/v2026.1003.1.md create mode 100644 src/engine/edge-sites.ts create mode 100644 src/mcp/review-tools.test.ts create mode 100644 src/mcp/review-tools.ts create mode 100644 src/reporting/commands/init.test.ts create mode 100644 src/review/base-graph.test.ts create mode 100644 src/review/base-graph.ts create mode 100644 src/review/data-models.test.ts create mode 100644 src/review/data-models.ts create mode 100644 src/review/derive.test.ts create mode 100644 src/review/derive.ts create mode 100644 src/review/doc-build.ts create mode 100644 src/review/doc-comments.test.ts create mode 100644 src/review/doc-comments.ts create mode 100644 src/review/doc-store.test.ts create mode 100644 src/review/doc-store.ts create mode 100644 src/review/doc.test.ts create mode 100644 src/review/doc.ts create mode 100644 src/review/explain-doc.test.ts create mode 100644 src/review/explain-doc.ts create mode 100644 src/review/groups.test.ts create mode 100644 src/review/groups.ts create mode 100644 src/review/multi-repo.test.ts create mode 100644 src/review/multi-repo.ts create mode 100644 src/review/provenance-cloud.test.ts create mode 100644 src/review/provenance-cloud.ts create mode 100644 src/review/provenance.test.ts create mode 100644 src/review/provenance.ts create mode 100644 src/review/session.test.ts create mode 100644 src/review/session.ts create mode 100644 test/edge-sites.test.ts create mode 100644 test/path-calls.test.ts diff --git a/DOCS.md b/DOCS.md index 6c49c4c..6ad775e 100644 --- a/DOCS.md +++ b/DOCS.md @@ -466,6 +466,8 @@ vg review --loop # review → deterministic patch → re-rev vg review --base origin/main # merge-base of HEAD and the base branch vg review explain arch:: # the evidence behind one finding vg review findings-from-diff # deterministic graph/policy findings only +vg review groups --base origin/main # the change grouped for reading +vg review doc --base origin/main # the review document, every claim pinned to lines vg review propose blast: --model forge --json ``` @@ -489,6 +491,306 @@ vg review propose blast: --model forge --json `vg review` reads the code map, so run `vg` (or `vg build`) in the repository first. Without a map it exits `6` — never `0`. +#### Reading the change: groups and the review document + +Before anyone judges a change, they have to read it. `vg review groups` puts +the change in reading order, and `vg review doc` turns it into a review +document. Neither needs a model, and neither builds a code map. Grouping and +diagrams come from the **Architecture** module (`vg module install arch`); +without it every changed file is listed in one "not grouped" group, and the +output says how to get the rest. + +```bash +vg review groups # working tree vs HEAD +vg review groups --base origin/main # a branch, against its merge-base +vg review groups --format json # vg.review.groups.v1 +vg review doc --base origin/main # Markdown, ready for a pull request +vg review doc --base origin/main --base-graph # also map the base commit: before/after call paths +vg review doc --format json -o review.json +vg review doc --check review.json # validate an edited document +vg review doc --session latest # what the last VG Code chat did +``` + +**Groups.** Every changed file lands in exactly one group. Files that are not +implementation are peeled off first, each for a reason you can see in the +output: + +| Group | What lands there | +|---|---| +| Moved or renamed, no edits | renames with no changed lines | +| Generated files | code a tool wrote, from `.gitattributes`, the path, or the file's header | +| Dependencies and lockfiles | lockfiles and dependency manifests | +| Fixtures and snapshots | test fixtures and recorded snapshots | +| Tests | test files | +| Data and content | structured data that is not config, a manifest, an API contract or infrastructure code | +| Docs, Assets | prose, licences, images, fonts, media | +| Config and build | CI pipelines, container files, build and tooling config | +| Formatting only | the diff is empty once whitespace and blank lines are ignored | +| Imports only | every changed line is an import | + +What is left is implementation, grouped by monorepo area (`packages/api`) and +architecture layer, in reading order, with large groups split so each fits in +one sitting. Each file carries the reason it landed where it did. The output +is deterministic: the same change always produces the same groups and the +same `digest`. + +An agent or a person may merge or split groups and save the result. `vg review +groups --check ` exits `2` unless every changed file is in exactly one +group and nothing outside the change is listed. Anything left out is +uncategorized, and uncategorized fails. + +**The review document** (`vg.review.doc.v1`) has up to four sections, always in +this order: *what and why*, *requirements*, *design*, *implementation*. `vg +review doc` writes what it can prove: a summary of the change, every +implementation group with a link to each changed hunk, and, when a code map +already exists, the deterministic findings with their evidence and diagrams +derived from the map. It leaves the why and the requirements to the author, +and says so in the document. `vg review doc` reads an existing code map but +never builds one; run `vg` first. + +**Diagrams come from the code map, not from a model.** They are derived by the +**Architecture** module (`vg module install arch`); without it the document +has no diagrams and says how to get them. Whatever the module returns, `vg` +checks every pin against the reviewed base and head before showing it. With a +code map and the module, the design section gets: + +- **A call path** into the changed function where most of the change happened: + from an entry point (a function nothing calls, or a file's top level) down + through each caller. Each frame is pinned to the function's lines and to + the line it is called from, and says how it is reached: a plain call, an + awaited (*async*) call, a function passed as a value (*callback*), or a call + made where the caller publishes to a queue or calls out over HTTP. A path + longer than 8 frames keeps its entry point, the changed functions and the + last callers, and says how many calls it folded. Frames are marked *new*, + *edited* or *gone*. +- **The before side** of that path, when you pass `--base-graph`. vg checks out + the base commit in a temporary worktree, maps it, and removes the worktree. + Unchanged files reuse their cached parses, but this still takes seconds to + tens of seconds on a large package, so it is opt-in. Without it the before + side reads *not computed*, never an empty path. +- **Up to three flows**: the statements the map extracted from a changed + function, in order, with each condition as a decision and a `catch` on a + dashed error edge. Steps on changed lines are highlighted. +- **The data the change reads and writes**, when the repository declares its + data model in Prisma (`*.prisma`), SQL DDL (`CREATE TABLE`) or EF Core + (`DbSet` on a context, and the entity classes). The tables the changed + functions read and write, and the tables they reference, with every column, + key and foreign key linked to the line that declares it; each read and write + at the line that runs it, followed into the repository or handler the change + calls; and the entry points that reach them as use cases, including those + that dispatch through a mediator rather than a direct call. A save that + names no table (`SaveChangesAsync`) writes what the function loaded or added, + not every row it looked at. A read or write that names no declared table is + left out and counted in the notes. Connection strings are never read. +- **Where the change sits**, when the code map has its architecture (`vg` + builds it with the Architecture module): a map that zooms from the system + to the code. The containers are the packages the change touches and their + most-connected neighbors, with the package-to-package call counts + `vg show arch` shows; inside them, the changed functions, the functions + that run their reads and writes, and the entry points that reach them, + grouped into components by their architecture role (controllers, use + cases, repositories, …); and the data stores they read and write. Calls, + dispatches through a mediator, and reads and writes are drawn as edges; + what the change touched is marked edited or new. Test packages are never + containers. +- **Signature changes**, with `--base-graph`: a changed function whose + signature differs from the base commit is called out under implementation, + with the before and after and how many callers reach it. + +Every node, frame and edge is marked `origin: graph`. A diagram whose pins do +not land (usually a code map older than the change) is left out, and the notes +say why. Pass `--no-diagrams` or `--no-findings` to leave either out. + +Every claim about code is pinned: `[label](head:src/orders.ts#L10-L24)`, or +`base:` for the code before the change. `vg review doc --check ` exits +`2` when the document breaks a rule: + +- a pin names a file that does not exist on that side, or runs past its last line +- sections are out of order, or repeated +- the design section does not have exactly one primary diagram +- a flow diagram has an edge to a missing node, a branch that does not leave a + decision, or a process node with no pinned code +- a sequence step names an unknown actor, or has neither code nor a note +- a call-stack frame names a parent that does not come before it, or the + before side is marked *not computed* or *absent* but still lists frames +- a data-store operation or foreign key points at a store, collection or field + that is not defined +- a system-map element's parent is missing, or a relationship has a missing end +- the document was written for a different base or head + +The Markdown output renders flow, sequence and system-map blocks as Mermaid +diagrams, so they draw directly in a GitHub comment. + +**What a VG Code chat did.** `vg review doc --session ` (or `--session +latest`) writes the document for one VG Code chat, from what that chat +already saved under `.vibgrate/code-sessions/`. Nothing new is recorded. + +- Only the files the chat touched are included. The notes count the changed + files it left out, and name the files it touched that are no longer in the + change (committed past the base, or reverted; pass `--base` to include + commits). +- *What and why* says which chat made the change and quotes its first request. + The agent's last summary is quoted too, marked as the agent's own account + that vg has not checked against the code. +- *Requirements* lists each request, turn by turn, with links to the changed + lines in the files that turn touched. A turn that stopped before finishing + (out of steps, cancelled, an error) is called out. +- A chat that ran in its own worktree is read from that worktree, against the + commit it branched from, so you can read the document before applying the + worktree. Once the worktree is applied and removed, review the main tree. +- The document is marked `generator.by: "mixed"`: the requests are a person's + words, the summary is the model's, and everything else vg derived. + +`vg review groups --session ` groups the same files. + +**Read the shape before the lines.** With a code map and the Architecture +module, each implementation file in the document lists the functions the +change touched, as a structural fold: + +``` +- `src/orders.ts` modified +28 −0 · L4–31 + - `async function placeOrder(db: any, req…)` L5–31 new, 27 lines — query User via findUnique · query Product via findMany · log via console.log + - `function total(items)` L1–3 edited +``` + +- Every changed function shows its signature, whether it is new or edited, + and a link to its lines. A function inside another changed one is read with + it. +- A long new function (25 lines or more) is folded to what it does, step by + step, from the statements the code map extracted. No model writes the + summary. +- Tests and docs stay in their own groups, folded to a count, as before. +- A file whose lines no longer match the code map is left unfolded, and the + notes say to rebuild the map. + +**An agent writes the document.** `vg review doc` writes the deterministic +first pass. An agent (or a person) can then write the parts only it knows (the +why, the requirements, a diagram of the design) into a saved copy, block by +block, without ever being able to pass its words off as vg's. + +```bash +vg serve --review # adds the review_doc MCP tool (or VG_REVIEW=1) +vg review doc --save # save the document for this change; prints its id +vg review doc --saved # show the saved one while it still matches the change +vg review doc --doc rd_5da78d7992d5 --patch ops.json --expect-version 2 +vg review doc --doc rd_5da78d7992d5 --history +vg review doc --doc rd_5da78d7992d5 --restore 1 +``` + +- **One document per scope.** The change (or the change against a base ref), + or one VG Code chat, always opens the same saved document, `rd_…`. When the + commits move, opening it builds a new version from the change; the edited + versions stay in the history. +- **Patches by block id.** `set_text`, `insert`, `replace`, `remove`, `move`, + `set_primary` and `set_title`, up to 50 in one patch. A replaced block keeps + its id. +- **All or nothing.** After the operations apply, the whole document must pass + the same checks as `--check`: schema, section order, one primary diagram, + every pin landing on the change. Otherwise nothing is saved and the issues + come back. +- **Versions, not overwrites.** A patch names the version it was written + against, and a patch against an older one is refused, so two agents cannot + overwrite each other. A restore adds a new version; nothing is ever lost. +- **Provenance that cannot be forged.** Text an agent writes is marked + `origin: "agent"` and shows as *written by an agent*. A diagram element stays + `origin: "graph"` only while it is unchanged from what vg derived; an agent + that claims `graph` for something it drew or edited is recorded as `agent`, + and told so. +- **Kept for 365 days** after its last update (the Repository retention + category), then deleted. `.vibgrate/review-docs/` is in vg's own + `.gitignore`, so a saved document is never committed with the change it + describes. + +Over MCP, `review_doc` takes an `op`: `open` (the change, a `base`, or a VG +Code `session`), `get` (the outline and the document, or one `block`), +`patch`, `check`, `history`, `restore`. It is listed only under `vg serve +--review`: a default `vg serve` advertises the same tools as before. + +**A change across repositories.** When one change spans checkouts (an API +and the client that calls it), fold the others in with `--also`: + +```bash +vg review doc --base origin/main --also ../web-client +vg review doc --base origin/main --also ../web-client=origin/develop --push +vg review doc --base origin/main --check doc.json --also ../web-client +``` + +- Each checkout's part is built there, from its own diff and code map, the + same way `vg review doc` builds it, and joins the same four sections under a + heading naming the repository. The first repository keeps the primary + diagram. +- Every pin from another repository names it (`"repo": "web-client"`, or + `head@web-client:src/api.ts#L4` in text), and the document lists them under + `repos` with their commits. Each pin is checked against its own repository. +- `--check` checks pins in other repositories only against checkouts given + with `--also`, matched by repository; one it was not given is named, not + failed pin by pin. +- `--also` builds a document for a change; it does not combine with `--save`, + `--saved` or `--session`. With `--push`, the GitHub App shows it on the + first repository's pull request, and its pins link to each repository on + GitHub. + +**Show the document on the pull request.** `vg review doc --push` uploads the +whole document to Vibgrate Cloud. The GitHub App then shows it in its Review +summary comment, folded under the summary, with every pin linked to its lines +at the pull request's commits. + +```bash +vg review doc --base origin/main --push # in CI, after checkout, with VIBGRATE_DSN set +vg review doc --base origin/main --save --push +``` + +- **Opt-in on both sides.** The CLI sends a document only on `--push`; a + receipt push (`vg review --push`) never includes one. The workspace must also + have turned on **Review documents** (Vibgrate Cloud → Settings → Source + control). It is off by default, only a workspace admin can change it, and + the change is audited. While it is off, the upload is refused and nothing is + stored. +- **What is uploaded.** The whole document, which carries code-derived text: + function signatures, branch conditions, table and field names, the paths and + line numbers it pins, and anything an agent or a person wrote into it (for a + `--session` document, the requests made in that chat). Source files are not + uploaded. +- **Committed changes only.** The App shows the document for the commit it + describes, so a document of uncommitted work is refused before anything is + sent. Commit first, then run it with `--base`. +- **Sealed.** Cloud recomputes the document digest and refuses a document + edited after `vg` built it. +- **Kept for 365 days** (the Repository retention category), then deleted. + Turning **Review documents** off deletes every stored document for the + workspace at once. +- **Long documents.** The comment shows as much as fits GitHub's comment limit, + section by section, and says how many blocks were left out. +- **In Vibgrate Cloud.** The Review run page the comment links to draws the + whole document: diagrams, call paths, data tables and findings, with every + pin linked to its lines on GitHub. It re-reads the document every 30 seconds, + so a new push for the same commit (an agent's edits saved with `--save + --push`, say) shows without a reload. +- **Comments an agent can answer.** On that page anyone who can see the run + can comment on a block, reply, and resolve a thread. The agent reads and + answers them where it works: + + ```bash + vg review doc --base origin/main --comments # open threads first + vg review doc --base origin/main --reply rdc_3f2a… --text "Because the base side was not built." + ``` + + Over MCP, `review_doc` op `comments` and op `reply` do the same for a saved + document (`vg serve --review`). A reply sent this way is always shown as an + agent's, and an agent can only answer a thread: it cannot start or resolve + one. Comments are plain text, at most 4000 characters, and are deleted with + their document (365 days, or when review documents are turned off). A thread + on a block a later push removed stays visible, marked as outdated. + + The GitHub summary links each block to its comments: **Comment** opens the + block on the run page with the comment box open, and "2 open comments" + opens the first open thread. In Vibgrate for VS Code the threads show under + their blocks in the review document tab, and new comments from people add a + row to VG Code with **Answer them**, which puts the threads and the reply + command into the composer, unsent. Settings → Source control's "What landed + this week?" links each merged pull request to its review, and to its review + document when one was pushed. + #### Decisions Policy owns the decision, and it is the only layer that writes one. Findings — @@ -883,6 +1185,39 @@ vg why `vg why` reads your lockfile's history, so it works across npm / pnpm / yarn, pip / poetry, cargo, composer, bundler, go, pub, hex, NuGet, and Maven/Gradle projects. For Maven/Gradle the history comes from a resolved `gradle.lockfile`, or a `pom.xml`'s pinned direct-dependency versions (versions managed by a BOM/`dependencyManagement` aren't resolved). Open vulnerabilities and their introduction attribution come from your most recent `vg scan --vulns`. +#### Which agent session wrote a line + +Pass `` instead of a package to see the commit that last changed that line. If a VG Code session made the change, you also see what it was asked. + +```bash +vg review trailer on # opt in for this repository +vg why src/orders.ts:42 +``` + +`vg review trailer on` installs a git `prepare-commit-msg` hook. When a commit includes files that a VG Code session changed in the last 14 days, the hook adds a `Vibgrate-Session: ` trailer to the message. + +- **Only the session's random id is committed.** The session itself (what was asked and what the agent answered) stays in `.vibgrate/code-sessions/` on the machine where it ran. +- **The hook never blocks a commit.** If anything fails, the commit goes ahead without a trailer. Merge and squash messages are left alone. +- **An existing hook is never replaced.** If another tool already owns `prepare-commit-msg`, `vg review trailer on` changes nothing and prints the one line to add to that hook. + +`vg why ` blames the line, reads the commit's trailers and shows each session's title and model. It then lists the requests in that session that touched the file. The agent's own answer is shown as its account, unverified. A session that ran on another machine is named, but its contents are not shown. `vg review trailer off` removes the hook, and `vg review trailer status` shows whether it is on. Both take `--json`. + +**Sessions that ran on another machine.** A session stays where it ran, so a teammate's `vg why` shows only its id. To share the sessions behind your commits with your Vibgrate Cloud workspace: + +```bash +vg review trailer push --dry-run # list what would be sent +vg review trailer push # sessions named on commits no remote branch has yet +vg review trailer push --base origin/main +vg why src/orders.ts:42 --cloud # a teammate reads them back +``` + +- **Opt-in on both sides.** Sessions are sent only when you run `push`. Vibgrate Cloud stores them only when a workspace admin has turned on Agent provenance under Source control. It is off by default. +- **What is sent:** per turn, the request, the agent's summary, the repo-relative files it changed and a timestamp. File contents, diffs and attachments are never sent. +- **Credentials:** they are masked before sending. A session that still carries one is not sent, and `push` says which. +- **Retention:** 365 days from the first upload. Turning the setting off deletes every stored session at once. + +Both commands use your DSN (`vg login`, `VIBGRATE_DSN` or `--dsn`). + --- @@ -1642,19 +1977,28 @@ Set `VIBGRATE_NO_KERNEL=1` to disable optional modules entirely — installs are ### vg path -Show how A connects to B — shortest path in the call graph. +Show how A connects to B — the shortest path through the code map. By default +any edge counts (calls, imports, containment, tests); `--calls` follows call +edges only, which is the path that actually runs, and prints each hop's +call-site line and whether the call is awaited. ```bash vg path +vg path handler insert --calls ``` | Flag | Description | |------|-------------| | `` | Source node | | `` | Target node | +| `--calls` | Follow call edges only; show the call-site line of each hop | | `--pick-a ` | Pick the nth candidate for A | | `--pick-b ` | Pick the nth candidate for B | +With `--json`, `steps` lists each hop's edge kind, resolver, call-site line and +`awaited` flag. The `find_path` MCP tool returns the same as `hops`, and takes +`calls_only: true` for the call-only path. + --- ### vg savings @@ -1749,9 +2093,24 @@ vg show |------|-------------| | `` | Qualified name, short name, `file:line`, glob, or id | | `--pick ` | Pick the nth candidate when ambiguous | +| `--diagram` | Explain the node with pinned diagrams instead of text (needs the Architecture module) | +| `--format ` | With `--diagram`: `md` (default) or `json` (a `vg.review.doc.v1` document with `kind: "explain"`) | Outputs the qualified name, kind, file location, signature, importance score, area, extends relationships, callees, and callers. For functions and methods it also prints the architecture classification the Architecture module wrote at build time — role, purposes, a one-line description, and any boundary violation — when that module is loaded (`vg module install arch`; installed by default). +With `--diagram`, the same facts become a document for code as it is, not for a change: + +```bash +vg show OrderService.save --diagram +vg show src/orders/service.ts:42 --diagram --pick 1 --format json +``` + +- **What it is:** the kind, the file, the signature, the architecture role, the area, and how many callers and callees it has. +- **How it works:** how the code is reached (the call path from its entry point), what it does (a flow of its statements, branches and error paths), the data it reads and writes when the repository declares its data models, and where it sits (system, packages, components, stores). +- **Callers and callees:** each one linked to the lines that declare it. + +Every element is pinned to lines in the working tree and comes from the code map, so nothing is marked new or edited and the call path has no before side. When nothing in the code map calls the code, the flow leads instead. The document is the same `vg.review.doc.v1` that `vg review doc` writes, so the same renderers and checks apply. In VS Code, **Vibgrate: Explain This Code with Diagrams** opens it for the function under the cursor. + #### vg show arch Open a local, interactive architecture map of the same graph in your browser. diff --git a/README.md b/README.md index c8d1748..3346e40 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ written to disk, and never phones home. `vg serve config` lists every knob and - **search_symbols** — find a symbol by name or literal string. - **query_graph** — find code by meaning: symptoms, relationships, what-breaks-if. - **get_node** — inspect one symbol: signature, callers, callees, area. -- **find_path** — shortest connection between two symbols. +- **find_path** — shortest connection between two symbols, with each hop's edge kind; `calls_only` follows calls and gives each call-site line. - **impact_of** — blast radius of a change: dependents, files, covering tests, risk. - **tests_for** — which tests cover a symbol. - **get_graph_summary** — code map overview: counts, languages, top areas and hubs. @@ -176,10 +176,11 @@ written to disk, and never phones home. `vg serve config` lists every knob and - **library_docs** — version-correct usage docs for a library, sliced to a token budget. - **compress_content** / **retrieve_original** / **compression_stats** (with `--compress`) — shrink a tool output before it enters the context, expand a marker back to the original or just the slice you need, and report what compression saved. - **memory_search** / **memory_save** (with `--memory`) — project-scoped memory shared across your AI agents. +- **review_doc** (with `--review`) — write the review document for a change: open it, patch a block by id, check every pin, read the history, restore a version, and read and answer the comments people left on the pushed document in Vibgrate Cloud. Saved under `.vibgrate/review-docs/`, never in the repository. -The last two groups are listed only when you ask for them. Every advertised +The last three groups are listed only when you ask for them. Every advertised tool schema is re-sent on every agent step, so a capability nobody enabled is a -standing cost; both groups stay callable either way. +standing cost. Prefer the hosted server over your team's scan data? **[Vibgrate Cloud MCP](https://vibgrate.com/mcp)** connects your assistant to Vibgrate Cloud (OAuth 2.1, 51 tools). @@ -768,6 +769,8 @@ All HCS computation runs in an optional, separately-licensed engine module that | `vg scan --vulns` | Also detect known vulnerabilities (OSV; offline via `--package-manifest`) | | `vg update` | Check for and install updates | | `vg why ` | Who introduced a dependency, its version history, and any open vulnerabilities | +| `vg why ` | The commit that last changed a line, and the VG Code session that wrote it (opt in with `vg review trailer on`) | +| `vg review trailer push` | Share the VG Code sessions behind your commits with Vibgrate Cloud, so `vg why --cloud` works for teammates | ### Workspace auth & cloud upload diff --git a/action.yml b/action.yml index 62c3c7e..e3cc54f 100644 --- a/action.yml +++ b/action.yml @@ -46,7 +46,7 @@ inputs: image-tag: description: 'Scanner image tag to run (defaults to a pinned, tested release).' required: false - default: '2026.930.1' # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs + default: '2026.1003.1' # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs verify: description: 'Verify the image cosign signature + provenance before running (requires cosign on the runner).' required: false diff --git a/charts/vibgrate/Chart.yaml b/charts/vibgrate/Chart.yaml index 2693e40..96bcb29 100644 --- a/charts/vibgrate/Chart.yaml +++ b/charts/vibgrate/Chart.yaml @@ -6,8 +6,8 @@ type: application # independently of the CLI. appVersion pins the tested scanner image tag and is # stamped to the released @vibgrate/cli calendar version by # scripts/stamp-release-pins.mjs (via the marker on the appVersion line below). -version: 0.1.3 -appVersion: "2026.930.1" # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs +version: 0.1.2 +appVersion: "2026.1003.1" # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs home: https://vibgrate.com icon: https://vibgrate.com/web-app-manifest-512x512.png sources: diff --git a/package.json b/package.json index 6ea3d1e..ca39cdf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vibgrate/cli", - "version": "2026.930.1", + "version": "2026.1003.1", "description": "vg — local codebase intelligence CLI + MCP server for AI coding agents: deterministic code graph, drift reporting, and version-correct library docs (Apache-2.0)", "//mcpName": "Official MCP registry ownership proof: the registry fetches the published npm package and requires this field to match the com.vibgrate/ai-context server entry (see docs/marketing/mcp-registry/README.md). Must ship in the published @vibgrate/cli package.json.", "mcpName": "com.vibgrate/ai-context", diff --git a/packaging/homebrew-tap/Formula/vg.rb b/packaging/homebrew-tap/Formula/vg.rb index b6e5fad..40a501e 100644 --- a/packaging/homebrew-tap/Formula/vg.rb +++ b/packaging/homebrew-tap/Formula/vg.rb @@ -3,8 +3,8 @@ class Vg < Formula desc "Deterministic, no-API-key code graph for AI assistants (vg)" homepage "https://vibgrate.com" - url "https://registry.npmjs.org/@vibgrate/cli/-/cli-2026.930.1.tgz" - sha256 "b33508a85a7c9dcb06151b64c90363c15eeb03a74f7d3bd30a41e6a38af05c11" + url "https://registry.npmjs.org/@vibgrate/cli/-/cli-2026.914.1.tgz" + sha256 "21c164080d1ba33dc53d604a8754ffa0079daa9c8b771a9053c224a2c43877bf" license "Apache-2.0" depends_on "node" diff --git a/packaging/scoop-bucket/vg.json b/packaging/scoop-bucket/vg.json index c212246..b76a0cc 100644 --- a/packaging/scoop-bucket/vg.json +++ b/packaging/scoop-bucket/vg.json @@ -1,5 +1,5 @@ { - "version": "2026.930.1", + "version": "2026.914.1", "description": "Deterministic, no-API-key code graph for AI assistants (vg)", "homepage": "https://vibgrate.com", "license": "Apache-2.0", diff --git a/releases/v2026.1003.1.md b/releases/v2026.1003.1.md new file mode 100644 index 0000000..fa5eaf8 --- /dev/null +++ b/releases/v2026.1003.1.md @@ -0,0 +1,53 @@ +# Vibgrate CLI 2026.1003.1 + +_Released 2026-10-03_ + +This release of Vibgrate CLI introduces several new features and improvements focused on enhancing the review process and collaboration among team members. Key updates include improved visibility of code sessions, enhanced document generation capabilities, and better handling of review comments. + +## What changed + +### New + +- Teammates can now see which VG Code session wrote a line, even when it ran on another machine. +- `vg review trailer push` uploads sessions named by `Vibgrate-Session` trailers on unpushed commits. +- `vg why --cloud` retrieves session information from Vibgrate Cloud. +- `vg serve --review` allows agents to write review documents with new operations. +- `vg review doc --comments` lists comments on the pushed review document. +- `vg review doc` now shows the data a change reads and writes based on the repository's data model. +- `vg review doc --also ../client` allows changes across repositories to be included in one review document. +- `vg review doc --push` uploads the review document to Vibgrate Cloud for GitHub App integration. +- `vg review doc --session ` generates a review document for a specific VG Code chat. +- `vg show --diagram` provides a visual explanation of code with pinned diagrams. + +### Fixed + +- `vg init` now correctly suggests `vg scan` and `vg baseline` as next commands. +- `vg review` no longer lists renamed files multiple times. + +## Benchmarks + +Two-arm benchmark of this release against 2026.930.1, interleaved on one runner against the pinned corpus (236 metrics compared). + +| Metric | Previous | This release | +| --- | --- | --- | +| Languages with extraction | 19 count | 19 count | +| Definitions extracted (corpus total) | 26227 count | 26227 count | +| Call edges extracted (corpus total) | 18264 count | 18264 count | +| Locate accuracy (top-1) | 0.94 ratio | 0.94 ratio | +| Dependency detection (authored manifest truth) | 0.96 ratio | 0.96 ratio | +| CLI startup (--version, median) | 622 ms | 624.50 ms | + +2 regression(s) — published, not omitted: +- Tasks passed on both arms: 36 → 34 (-5.6%) +- Comparable-task rate (both arms passed / total): 0.95 → 0.89 (-5.6%) + +Full report and methodology: https://vibgrate.com/cli/benchmarks + +## Install or update + +```sh +npm install -g @vibgrate/cli +vg +``` + +Full changelog: https://vibgrate.com/changelog/cli/2026.1003.1 diff --git a/src/commands/path.ts b/src/commands/path.ts index fa0640e..57f7f97 100644 --- a/src/commands/path.ts +++ b/src/commands/path.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import { resolveOne } from '../engine/lookup.js'; -import { pathDisconnect, shortestPath } from '../engine/paths.js'; +import { callPath, describeHops, pathDisconnect, shortestPath } from '../engine/paths.js'; import { recordCliCall, CLI_TOOL_ALIASES } from '../engine/savings.js'; import { applyGlobalOptions, readGlobal } from '../cli-options.js'; import { requireGraph, rootOf } from './util.js'; @@ -14,12 +14,13 @@ import { c, info, json } from '../util/output.js'; export function registerPath(program: Command): void { const cmd = program .command('path') - .description('how A connects to B (shortest path)') + .description('how A connects to B (shortest path; --calls follows call edges only)') .argument('', 'source node') .argument('', 'target node') .option('--pick-a ', 'pick the nth candidate for A when ambiguous') .option('--pick-b ', 'pick the nth candidate for B when ambiguous') - .action(function (this: Command, a: string, b: string, opts: { pickA?: string; pickB?: string }) { + .option('--calls', 'follow call edges only, and show the call-site line of each hop') + .action(function (this: Command, a: string, b: string, opts: { pickA?: string; pickB?: string; calls?: boolean }) { const global = readGlobal(this); const { graph } = requireGraph(global); @@ -28,7 +29,7 @@ export function registerPath(program: Command): void { const rb = resolveOne(graph, b, opts.pickB ? Number(opts.pickB) : undefined); if (!rb.node) throw ambiguityError(`"${b}" ${rb.candidates.length ? 'is ambiguous' : 'not found'}`, rb.candidates, '--pick-b'); - const result = shortestPath(graph, ra.node.id, rb.node.id); + const result = opts.calls ? callPath(graph, ra.node.id, rb.node.id) : shortestPath(graph, ra.node.id, rb.node.id); // Record the call for the command-vs-MCP split when an AI identified itself // (before the not-found throw, so a no-path attempt is counted as a miss). if (global.client) { @@ -62,14 +63,32 @@ export function registerPath(program: Command): void { const byId = new Map(graph.nodes.map((n) => [n.id, n] as const)); const names = result.ids.map((id) => byId.get(id)?.qualifiedName ?? id); + const steps = describeHops(graph, result.ids, result.direction); if (global.json) { - json({ from: ra.node.qualifiedName, to: rb.node.qualifiedName, hops: names.length - 1, direction: result.direction, path: names }); + json({ + from: ra.node.qualifiedName, + to: rb.node.qualifiedName, + hops: names.length - 1, + direction: result.direction, + path: names, + steps, + ...(opts.calls ? { callsOnly: true } : {}), + }); return; } - info(`${c.cyan('vg path')} · ${names.length - 1} hop(s)${result.direction === 'reverse' ? c.dim(' (reverse)') : ''}`); - info(' ' + names.map((n) => c.bold(n)).join(c.dim(' → '))); + info(`${c.cyan('vg path')} · ${names.length - 1} hop(s)${opts.calls ? c.dim(' · calls only') : ''}${result.direction === 'reverse' ? c.dim(' (reverse)') : ''}`); + if (!opts.calls) { + info(' ' + names.map((n) => c.bold(n)).join(c.dim(' → '))); + return; + } + info(` ${c.bold(names[0])}`); + for (const s of steps) { + const where = s.line ? ` ${s.file ?? ''}:${s.line}` : ''; + const how = [s.awaited ? 'awaited' : null, s.resolution].filter(Boolean).join(', '); + info(` ${c.dim('→')} ${c.bold(s.to)}${c.dim(` ${s.kind}${where} · ${how}`)}`); + } }); applyGlobalOptions(cmd); } diff --git a/src/commands/review.ts b/src/commands/review.ts index aadab1d..ee06812 100644 --- a/src/commands/review.ts +++ b/src/commands/review.ts @@ -36,7 +36,7 @@ import { c, info, out } from '../util/output.js'; import { rootOf } from './util.js'; import { formatExplain, formatMarkdown, formatSarif, formatText, type ReviewFormat } from '../review/format.js'; import { exitCodeForDecision, resolveFailOn, FAIL_ON_LEVELS, type FailOnLevel } from '../review/policy.js'; -import { buildEnvelope, collectSpans, pushReceipt, rejectPushWhenOffline, type ReviewPushBody } from '../review/push.js'; +import { buildEnvelope, collectSpans, pushReceipt, pushReviewDoc, rejectPushWhenOffline, reviewDocEnvelope, type ReviewPushBody } from '../review/push.js'; import { applyPatches, collectLoopPatches, loopNote, REVIEW_LOOP_MAX } from '../review/loop.js'; import { loadGraph } from '../engine/load.js'; import { proposeFindingFix, REVIEW_PROPOSE_LOOP_CAP } from '../review/propose.js'; @@ -51,7 +51,22 @@ import { resolveReviewSigningKey, verifyReceipt } from '../review/sign.js'; import { injectContextBlock, renderContext, writeContextFile } from '../review/context-file.js'; import { ensureCodeMap, reviewPolicyState, seedReviewPolicy } from '../review/prepare.js'; import { exportCorrectnessPublishRows } from '../review/finding-publish.js'; -import { changeSetFromUnifiedDiff, collectChangeSet, defaultRun, type ChangeSet } from '../review/git.js'; +import { changeSetFromUnifiedDiff, collectChangeSet, defaultRun, gitTopLevel, isGitRepo, normalizeRemote, repoKey, type ChangeSet } from '../review/git.js'; +import { addSessionTrailers, hookPath, setTrailer, trailerState, TRAILER } from '../review/provenance.js'; +import { preparePush, pushProvenance, trailerSessionIds } from '../review/provenance-cloud.js'; +import { cloudDsn, fetchComments, formatThreads, replyToComment } from '../review/doc-comments.js'; +import { loadHaileProvider } from '../engine/haile/haile-provider.js'; +import { collectGroupSignals, formatGroupsText, groupChangeSet, GROUPS_SCHEMA, validateGroups } from '../review/groups.js'; +import { buildDocument, resolveScope, scopeResolver, type BuildOptions, type DocScope } from '../review/doc-build.js'; +import { mergeDocuments, multiResolver, parseAlso, repoId, type OtherDoc } from '../review/multi-repo.js'; +import { checkAgainstChange, documentHistory, getDocument, openDocument, patchDocument, restoreVersion, savedOrBuilt } from '../review/doc-store.js'; +import { + DOC_SCHEMA, + renderReviewDocMarkdown, + validateReviewDoc, + type PinResolver, + type ReviewDoc, +} from '../review/doc.js'; import { parseDsn } from '../reporting/commands/push.js'; import { resolveDsn } from '../reporting/credentials.js'; @@ -390,6 +405,259 @@ export function registerReview(program: Command): void { }); applyGlobalOptions(verify); + const groupsCmd = cmd + .command('groups') + .description( + 'group the change for reading: implementation by area and layer first, then tests, config, dependencies, generated, docs, moves, formatting-only and import-only edits peeled off (deterministic; needs the Architecture module, no code map)', + ) + .option('--base ', 'group HEAD against the merge-base with (e.g. origin/main)') + .option('--in-place', 'include the working tree when --base is also set') + .option('--format ', 'output format (text | json)', 'text') + .option('--check ', 'validate an edited vg.review.groups.v1 file: every changed file in exactly one group (exit 2 if not)') + .option('--session ', 'only the files a VG Code session touched (`latest` for the most recent); a worktree chat is read from its worktree') + .action(async function (this: Command, opts: { base?: string; inPlace?: boolean; format: string; check?: string; session?: string }) { + const global = readGlobal(this); + const mainRoot = rootOf(global); + if (opts.format !== 'text' && opts.format !== 'json') { + throw new CliError('unknown --format (expected text | json)', ExitCode.USAGE_ERROR); + } + const resolved = resolveScope(mainRoot, scopeOf(opts)); + const change = resolved.change; + if (opts.check) { + const issues = validateGroups(readJsonFile(opts.check, mainRoot, GROUPS_SCHEMA), change); + reportIssues(issues, Boolean(global.json), opts.check); + return; + } + const groups = groupChangeSet(change, collectGroupSignals(change, resolved.sides), await loadHaileProvider()); + if (global.json || opts.format === 'json') out(JSON.stringify(groups, null, 2)); + else if (!global.quiet) info(formatGroupsText(groups)); + }); + applyGlobalOptions(groupsCmd); + + const trailer = cmd + .command('trailer') + .description( + 'opt in to a Vibgrate-Session commit trailer naming the VG Code session that changed the staged files (a git hook; only the session id is committed); `vg why ` reads it back, and `push` shares those sessions with your Vibgrate Cloud workspace', + ) + .argument('[state]', 'on | off | status | push', 'status') + .argument('[source]', 'internal: the commit message source git passes to the hook') + .option('--hook ', 'internal: run from the prepare-commit-msg hook') + .option('--base ', 'push: the sessions named in ..HEAD (default: commits no remote branch has yet)') + .option('--dsn ', 'push: DSN token (or use VIBGRATE_DSN / `vg login`)') + .option('--dry-run', 'push: list what would be sent, send nothing') + .action(async function (this: Command, state: string, source: string | undefined, opts: { hook?: string; base?: string; dsn?: string; dryRun?: boolean }) { + const global = readGlobal(this); + const root = rootOf(global); + const top = gitTopLevel(root); + if (opts.hook) { + // Called by git: never fail the commit, never print. + addSessionTrailers(top, opts.hook, state === 'status' ? source : state); + return; + } + if (!isGitRepo(root)) throw new CliError('`vg review trailer` needs a git repository', ExitCode.USAGE_ERROR); + if (state === 'push') { + await pushTrailerSessions(top, opts, Boolean(global.json)); + return; + } + if (state !== 'on' && state !== 'off' && state !== 'status') { + throw new CliError('use `vg review trailer on`, `off`, `status` or `push`', ExitCode.USAGE_ERROR); + } + const now = state === 'status' ? trailerState(top) : setTrailer(top, state === 'on'); + if (global.json) { + out(JSON.stringify({ trailer: now, hook: hookPath(top) })); + return; + } + if (now === 'other-hook') { + info(c.yellow(` ${hookPath(top)} is another tool's hook, so it was left alone.`)); + info(` To add the trailer there, add this line to it: ${c.bold('vg review trailer --hook "$1" "$2" || true')}`); + return; + } + info( + now === 'on' + ? ` ${c.green('on')}: commits that include files a VG Code session changed get a ${TRAILER} trailer with the session id. Only the id is committed; \`vg why \` reads it back.` + : ` ${c.dim('off')}: commits get no ${TRAILER} trailer.`, + ); + }); + applyGlobalOptions(trailer); + + const docCmd = cmd + .command('doc') + .description( + 'write the review document for this change (vg.review.doc.v1): what changed, grouped, with every hunk and finding pinned to verified lines; --check validates one an agent or person edited; --save keeps it so an agent can patch it block by block', + ) + .option('--base ', 'review HEAD against the merge-base with (e.g. origin/main)') + .option('--in-place', 'include the working tree when --base is also set') + .option('--format ', 'output format (md | json)', 'md') + .option('-o, --out ', 'write the document to a file') + .option('--no-findings', 'leave out deterministic findings') + .option('--no-diagrams', 'leave out the diagrams derived from the code map') + .option('--base-graph', 'also build the code map at the base commit, so call paths show before and after (slower; uses a temporary worktree)') + .option('--check ', 'validate a vg.review.doc.v1 file against this change: schema, section order, one primary diagram, every pin lands (exit 2 if not)') + .option('--session ', 'what a VG Code session did (`latest` for the most recent): only the files it touched, with each request as a pinned requirement; a worktree chat is read from its worktree') + .option('--save', 'save the document under .vibgrate/review-docs so an agent can patch it (the same scope always opens the same saved document)') + .option('--saved', 'show the saved document for this scope when one still matches the change, else build it (never saves)') + .option('--doc ', 'a saved document by id (rd_…), for --patch, --history and --restore, or to print it') + .option('--patch ', 'apply a JSON array of operations to the saved document named by --doc (needs --expect-version)') + .option('--expect-version ', 'the version the patch was written against; a patch against an older version is refused') + .option('--history', 'list the versions of the saved document named by --doc') + .option('--restore ', 'make version of the saved document named by --doc current again, as a new version') + .option('--push', 'also upload the whole document to Vibgrate Cloud, so the GitHub App shows it on the pull request (needs a DSN, committed changes, and review documents turned on for the workspace)') + .option('--dsn ', 'DSN token for --push, --comments and --reply (or use VIBGRATE_DSN / `vg login`)') + .option('--comments', 'list the comments people left on the pushed document for this change, open threads first') + .option('--reply ', 'answer a comment thread on the pushed document (with --text); the reply is shown as written by an agent') + .option('--text ', 'the reply for --reply') + .option( + '--also ', + 'fold in the change in another checkout (an API and its client): built there, its pins name that repository; repeatable; `=ref` sets its base', + (v: string, prev: string[] = []) => [...prev, v], + ) + .action(async function ( + this: Command, + opts: { + base?: string; + inPlace?: boolean; + format: string; + out?: string; + findings?: boolean; + diagrams?: boolean; + baseGraph?: boolean; + check?: string; + session?: string; + save?: boolean; + saved?: boolean; + doc?: string; + patch?: string; + expectVersion?: string; + history?: boolean; + restore?: string; + push?: boolean; + dsn?: string; + comments?: boolean; + reply?: string; + text?: string; + also?: string[]; + }, + ) { + const global = readGlobal(this); + const mainRoot = rootOf(global); + if (opts.format !== 'md' && opts.format !== 'json') { + throw new CliError('unknown --format (expected md | json)', ExitCode.USAGE_ERROR); + } + const asJson = Boolean(global.json) || opts.format === 'json'; + const emit = (doc: ReviewDoc): void => { + const text = asJson ? `${JSON.stringify(doc, null, 2)}\n` : renderReviewDocMarkdown(doc); + if (opts.out) { + fs.writeFileSync(path.resolve(mainRoot, opts.out), text); + if (!global.quiet) info(c.dim(` review document written to ${opts.out}`)); + } else { + out(text.replace(/\n$/, '')); + } + }; + const say = (line: string): void => { + if (!global.quiet) info(c.dim(` ${line}`)); + }; + + if (opts.patch || opts.history || opts.restore !== undefined || (opts.doc && !opts.check)) { + if (!opts.doc) throw new CliError('--patch, --history and --restore need --doc (from `vg review doc --save`)', ExitCode.USAGE_ERROR); + const id = opts.doc; + try { + if (opts.history) { + const h = documentHistory(mainRoot, id); + if (asJson) out(JSON.stringify(h, null, 2)); + else for (const v of h.versions) info(` v${v.version}${v.version === h.current ? ' (current)' : ''} ${v.at} ${v.by} ${v.summary}`); + return; + } + if (opts.restore !== undefined || opts.patch) { + const outcome = + opts.restore !== undefined + ? restoreVersion(mainRoot, id, Number(opts.restore)) + : patchDocument(mainRoot, id, Number(opts.expectVersion ?? NaN), readJsonFile(opts.patch!, mainRoot, 'patch operations')); + if (!outcome.ok) { + if (asJson) out(JSON.stringify(outcome, null, 2)); + else { + info(` ${c.red('NOT SAVED')} ${id} is at version ${outcome.version}`); + for (const e of outcome.errors) info(` ${e}`); + for (const i of outcome.issues) info(` ${c.dim(i.path)} ${i.message} ${c.dim(`[${i.code}]`)}`); + } + process.exitCode = ExitCode.GATE_FAILED; + return; + } + say(`${id} saved as version ${outcome.version}`); + for (const n of outcome.notes) say(n); + emit(outcome.doc); + return; + } + emit(getDocument(mainRoot, id).doc); + return; + } catch (err) { + if (err instanceof CliError) throw err; + throw new CliError((err as Error).message, ExitCode.NOT_FOUND); + } + } + + const scope = scopeOf(opts); + if (opts.comments || opts.reply) { + // The pushed document for this change: same repository, head and base as `--push` sends. + const { change } = resolveScope(mainRoot, scope); + const target = { repo_key: repoKey(change.remote, change.topLevel), head_sha: change.headSha, base_sha: change.baseSha }; + const dsn = cloudDsn(opts.dsn); + if (opts.reply) { + if (!opts.text?.trim()) throw new CliError('--reply needs --text with the answer', ExitCode.USAGE_ERROR); + const posted = await replyToComment(dsn, target, opts.reply, opts.text, 'vg review doc'); + if (global.json) out(JSON.stringify(posted, null, 2)); + else say(`replied to ${opts.reply} as ${posted.authorName}`); + return; + } + const { title, threads } = await fetchComments(dsn, target); + if (global.json) out(JSON.stringify({ title, threads }, null, 2)); + else out([title, ...formatThreads(threads)].join('\n')); + return; + } + if (opts.check) { + const resolved = resolveScope(mainRoot, scope); + const parsed = readJsonFile(opts.check, mainRoot, DOC_SCHEMA); + // A multi-repo document's other repositories are checked against the checkouts given with --also. + const others = new Map(); + for (const spec of (opts.also ?? []).map(parseAlso)) { + const otherRoot = path.resolve(mainRoot, spec.dir); + const base = spec.base ?? (scope.kind === 'change' ? scope.base : null); + const other = resolveScope(otherRoot, { kind: 'change', base, in_place: Boolean(opts.inPlace) }); + others.set(repoKey(other.change.remote, other.change.topLevel), scopeResolver(other)); + } + reportIssues(checkAgainstChange(parsed as ReviewDoc, resolved, others), Boolean(global.json), opts.check); + return; + } + const build = { findings: opts.findings, diagrams: opts.diagrams, baseGraph: opts.baseGraph, graphPath: global.graph, generatedAt: global.generatedAt, log: say }; + const show = async (doc: ReviewDoc): Promise => { + emit(doc); + if (opts.push) await pushDocument(mainRoot, doc, opts.dsn, say); + }; + if (opts.also?.length) { + if (opts.save || opts.saved || scope.kind === 'session') { + throw new CliError('--also builds a multi-repo document for a change; it does not combine with --save, --saved or --session', ExitCode.USAGE_ERROR); + } + await show(await buildMultiRepoDocument(mainRoot, scope, opts.also, { ...build, inPlace: opts.inPlace })); + return; + } + if (opts.save) { + // --save always writes a fresh version; earlier ones stay in the history. + const opened = await openDocument(mainRoot, scope, { ...build, fresh: true }); + say(`${opened.doc_id} saved as version ${opened.version}`); + await show(opened.doc); + return; + } + if (opts.saved) { + // Reading never writes: the saved version when it still matches, else a fresh build. + const shown = await savedOrBuilt(mainRoot, scope, build); + if (shown.saved) say(`${shown.saved.doc_id} version ${shown.saved.version}, as saved`); + else if (shown.note) say(shown.note); + await show(shown.doc); + return; + } + await show((await buildDocument(mainRoot, scope, build)).doc); + }); + applyGlobalOptions(docCmd); + const propose = cmd .command('propose') .description( @@ -677,6 +945,74 @@ async function doPush( if (!quiet) info(c.green('✔') + ` receipt pushed to ${res.host}`); } +/** + * `vg review doc --also [=]`: each other checkout's document is built + * there (its own code map and diff), then folded into this one with every pin + * naming its repository, and the whole is checked pin by pin before it is + * shown or pushed. + */ +async function buildMultiRepoDocument( + mainRoot: string, + scope: DocScope, + also: string[], + build: BuildOptions & { inPlace?: boolean }, +): Promise { + const primary = await buildDocument(mainRoot, scope, build); + const taken = new Set(); + const others: OtherDoc[] = []; + const resolvers = new Map(); + for (const spec of also.map(parseAlso)) { + const root = path.resolve(mainRoot, spec.dir); + if (!fs.existsSync(root)) throw new CliError(`--also ${spec.dir}: no such directory`, ExitCode.USAGE_ERROR); + const base = spec.base ?? (scope.kind === 'change' ? scope.base : null); + // Each checkout reads its own code map; --graph names only this one's. + const built = await buildDocument(root, { kind: 'change', base, in_place: Boolean(build.inPlace) }, { ...build, graphPath: undefined }); + const remote = built.resolved.change.remote; + const name = remote && remote.split('/').length >= 3 ? remote.split('/').slice(-2).join('/') : null; + const id = repoId(name, root, taken); + taken.add(id); + others.push({ id, name, doc: built.doc }); + resolvers.set(id, built.resolve); + } + const doc = mergeDocuments(primary.doc, others); + const issues = validateReviewDoc(doc, multiResolver(primary.resolve, resolvers)); + if (issues.length > 0) { + throw new CliError(`internal: merged review document failed validation — ${issues[0].path}: ${issues[0].message}`, ExitCode.ERROR); + } + return doc; +} + +/** + * `vg review doc --push`. The document is only useful on a pull request when + * it describes commits, so uncommitted work is refused before anything is + * sent; a refused or failed upload is an error, because the person asked for it. + */ +async function pushDocument(root: string, doc: ReviewDoc, dsnFlag: string | undefined, say: (line: string) => void): Promise { + if (doc.target.dirty_tree_hash) { + throw new CliError( + '--push needs a document of committed changes — commit first, then run `vg review doc --base origin/main --push`', + ExitCode.USAGE_ERROR, + ); + } + const dsn = resolveDsn(dsnFlag); + if (!dsn) throw new CliError('no DSN for --push — run `vg login`, set VIBGRATE_DSN, or pass --dsn', ExitCode.USAGE_ERROR); + const parsed = parseDsn(dsn); + if (!parsed) throw new CliError('invalid DSN format (expected vibgrate+https://:@/)', ExitCode.USAGE_ERROR); + const remoteRaw = defaultRun(['config', '--get', 'remote.origin.url'], root); + const remote = remoteRaw.status === 0 ? normalizeRemote(remoteRaw.stdout) : null; + const res = await pushReviewDoc(parsed, reviewDocEnvelope(doc, remote, root)); + if (!res.ok) { + if (res.code === 'review_doc_upload_disabled') { + throw new CliError( + 'this workspace does not accept review documents — a workspace admin can turn on Review documents in Vibgrate Cloud settings; nothing was uploaded', + ExitCode.ERROR, + ); + } + throw new CliError(`review document upload failed (${res.status}) — ${res.detail ?? ''}`, ExitCode.ERROR); + } + say(`review document uploaded to ${res.host}`); +} + /** * Write the committed agent memory. Kept separate from the receipt path because * this file is for humans and agents to read and commit, while the receipt is a @@ -768,3 +1104,68 @@ function formatFindingsFromDiff(result: RunReviewResult, base?: string): string ); return lines.join('\n'); } + +/** The scope a `review doc` / `review groups` invocation names. */ +function scopeOf(opts: { base?: string; inPlace?: boolean; session?: string }): DocScope { + return opts.session + ? { kind: 'session', session: opts.session, base: opts.base ?? null } + : { kind: 'change', base: opts.base ?? null, in_place: Boolean(opts.inPlace) }; +} + +function readJsonFile(spec: string, root: string, schema: string): unknown { + const abs = path.resolve(root, spec); + if (!fs.existsSync(abs)) throw new CliError(`no file at ${spec} — expected a ${schema} document`, ExitCode.NOT_FOUND); + try { + return JSON.parse(fs.readFileSync(abs, 'utf8')); + } catch { + throw new CliError(`${spec} is not valid JSON — expected a ${schema} document`, ExitCode.USAGE_ERROR); + } +} + +/** Print validation issues; exit 2 when there are any, so CI and agents can gate on it. */ +function reportIssues(issues: { path: string; code: string; message: string }[], json: boolean, file: string): void { + if (json) { + out(JSON.stringify({ file, valid: issues.length === 0, issues }, null, 2)); + } else if (issues.length === 0) { + info(` ${c.green('VALID')} ${file}`); + } else { + info(` ${c.red('INVALID')} ${file} — ${issues.length} issue${issues.length === 1 ? '' : 's'}`); + for (const i of issues) info(` ${c.dim(i.path)} ${i.message} ${c.dim(`[${i.code}]`)}`); + } + process.exitCode = issues.length === 0 ? ExitCode.OK : ExitCode.GATE_FAILED; +} + +/** + * `vg review trailer push`: share the VG Code sessions named by trailers on + * the commits being shared with the workspace, so a teammate's + * `vg why --cloud` can read them. Sessions that ran elsewhere are + * listed, not sent; Cloud stores nothing unless Agent provenance is on. + */ +async function pushTrailerSessions(top: string, opts: { base?: string; dsn?: string; dryRun?: boolean }, asJson: boolean): Promise { + const ids = trailerSessionIds(top, opts.base); + const prepared = preparePush(top, ids); + const summary = { + repo: prepared.repo.name, + sessions: prepared.sessions.map((s) => ({ id: s.id, title: s.title, turns: s.turns.length })), + missing: prepared.missing, + refused: prepared.refused, + }; + if (opts.dryRun || prepared.sessions.length === 0) { + if (asJson) out(JSON.stringify({ ...summary, sent: 0 })); + else { + if (ids.length === 0) info(c.dim(` no ${TRAILER} trailer on ${opts.base ? `${opts.base}..HEAD` : 'the commits no remote branch has yet'}`)); + for (const s of summary.sessions) info(` would send ${c.bold(s.id)}: ${s.title} ${c.dim(`(${s.turns} turns)`)}`); + for (const id of prepared.missing) info(c.dim(` ${id} is not on this machine, so it was not sent`)); + for (const r of prepared.refused) info(c.yellow(` ${r.id} was not sent: ${r.reason}`)); + } + return; + } + const stored = await pushProvenance(cloudDsn(opts.dsn), prepared); + if (asJson) { + out(JSON.stringify({ ...summary, sent: stored })); + return; + } + info(` ${c.green('sent')} ${stored} VG Code session${stored === 1 ? '' : 's'} to Vibgrate Cloud for ${prepared.repo.name}; \`vg why --cloud\` reads them back.`); + for (const id of prepared.missing) info(c.dim(` ${id} is not on this machine, so it was not sent`)); + for (const r of prepared.refused) info(c.yellow(` ${r.id} was not sent: ${r.reason}`)); +} diff --git a/src/commands/serve.ts b/src/commands/serve.ts index 6e24e9a..bac345e 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -79,7 +79,8 @@ export function registerServe(program: Command): void { // MCP transport, alive until it is stopped. Internal — not a user flag. .addOption(new Option('--compress-daemon').hideHelp()) .option('--memory', 'expose cross-agent memory tools (memory_search / memory_save) scoped to this project. Env: VG_MEMORY=1') - .action(async function (this: Command, agentArgv: string[], opts: { http?: boolean; port?: string; host?: string; savings?: boolean; shareStats?: boolean; dedup?: boolean; refresh?: boolean; watch?: boolean; surface?: string; tools?: string; compress?: boolean; compressOnly?: boolean; compressPort?: string; compressMode?: string; profile?: string; background?: boolean; compressDaemon?: boolean; memory?: boolean }) { + .option('--review', 'expose review_doc, so an agent can write and patch the review document for a change (saved under .vibgrate/review-docs). Env: VG_REVIEW=1') + .action(async function (this: Command, agentArgv: string[], opts: { http?: boolean; port?: string; host?: string; savings?: boolean; shareStats?: boolean; dedup?: boolean; refresh?: boolean; watch?: boolean; surface?: string; tools?: string; compress?: boolean; compressOnly?: boolean; compressPort?: string; compressMode?: string; profile?: string; background?: boolean; compressDaemon?: boolean; memory?: boolean; review?: boolean }) { const global = readGlobal(this); const root = rootOf(global); // `--compress-only` is the "I just want compression" path: no map is @@ -170,6 +171,7 @@ export function registerServe(program: Command): void { // callable either way — only the listing is gated. compressTools: compress, memory: opts.memory === true || /^(1|true|yes|on)$/i.test(process.env.VG_MEMORY ?? ''), + review: opts.review === true || /^(1|true|yes|on)$/i.test(process.env.VG_REVIEW ?? ''), graphless: compressOnly, }; // No map means nothing to refresh or watch. diff --git a/src/commands/show.ts b/src/commands/show.ts index 66819e8..08b2835 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -6,7 +6,11 @@ import { countTokens } from '../engine/tokens.js'; import { applyGlobalOptions, readGlobal } from '../cli-options.js'; import { requireGraph, rootOf } from './util.js'; import { ambiguityError } from './ambiguity.js'; -import { c, info, json } from '../util/output.js'; +import { c, info, json, out } from '../util/output.js'; +import { CliError, ExitCode } from '../util/exit.js'; +import { loadHaileProvider } from '../engine/haile/haile-provider.js'; +import { buildExplainDoc } from '../review/explain-doc.js'; +import { renderReviewDocMarkdown } from '../review/doc.js'; import { resolveGraphPath } from '../engine/artifacts.js'; import { findHaileSymbol, formatHaileLines, haileJsonFields, readHaileSidecar } from '../engine/haile/index.js'; import { registerShowArch } from './arch.js'; @@ -19,7 +23,8 @@ import { registerShowSurfaces } from './show-surfaces.js'; * `vg show arch` opens the local interactive architecture map of the same graph; * `vg show savings` opens the local page for what compression saved; * `vg show surfaces` lists the external services, models and MCP servers the - * last scan found. + * last scan found. `vg show --diagram` explains the node with pinned + * diagrams instead (review/explain-doc.ts). */ export function registerShow(program: Command): void { const cmd = program @@ -33,7 +38,9 @@ export function registerShow(program: Command): void { cmd .argument('', 'qualified name, short name, file:line, glob, or id') .option('--pick ', 'pick the nth candidate when ambiguous') - .action(function (this: Command, name: string, opts: { pick?: string }) { + .option('--diagram', 'explain it with pinned diagrams: how it is reached, its flow, the data it reads and writes, where it sits (needs the Architecture module)') + .option('--format ', 'with --diagram: output format (md | json)', 'md') + .action(async function (this: Command, name: string, opts: { pick?: string; diagram?: boolean; format: string }) { const global = readGlobal(this); const { root, graph } = requireGraph(global); const { node, candidates } = resolveOne(graph, name, opts.pick ? Number(opts.pick) : undefined); @@ -45,6 +52,16 @@ export function registerShow(program: Command): void { throw ambiguityError(`"${name}" is ambiguous`, candidates); } + if (opts.diagram) { + if (opts.format !== 'md' && opts.format !== 'json') { + throw new CliError('unknown --format (expected md | json)', ExitCode.USAGE_ERROR); + } + const { doc } = buildExplainDoc({ root, graph, node, graphPath: global.graph, provider: await loadHaileProvider() }); + const asJson = Boolean(global.json) || opts.format === 'json'; + out(asJson ? JSON.stringify(doc, null, 2) : renderReviewDocMarkdown(doc).replace(/\n$/, '')); + return; + } + const index = indexFor(graph); const callees = dedupe(index.callees(node.id).map((x) => x.node)); const callers = dedupe(index.callers(node.id).map((x) => x.node)); diff --git a/src/commands/why.ts b/src/commands/why.ts index 9dc8ce6..a66a91b 100644 --- a/src/commands/why.ts +++ b/src/commands/why.ts @@ -1,4 +1,10 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { Command } from 'commander'; +import { gitTopLevel, isGitRepo } from '../review/git.js'; +import { lineProvenance, sessionTitle, TRAILER, turnsTouching } from '../review/provenance.js'; +import { lookupProvenance, repoIdentity, type CloudLookup } from '../review/provenance-cloud.js'; +import { cloudDsn } from '../review/doc-comments.js'; import { buildVersionTimelines, findPackageAnyEcosystem, gitHistoryAvailable } from '../core-open/index.js'; import { readScanArtifact } from '../mcp/vuln-data.js'; import { applyGlobalOptions, readGlobal } from '../cli-options.js'; @@ -14,12 +20,20 @@ import { c, info, json } from '../util/output.js'; export function registerWhy(program: Command): void { const cmd = program .command('why') - .description('who introduced a dependency (and any open vulnerabilities), from git history') - .argument('', 'package name to explain') - .action(async function (this: Command, pkg: string) { + .description('who introduced a dependency (and any open vulnerabilities), from git history; or, for , which commit and VG Code session wrote that line') + .argument('', 'package name to explain, or file:line') + .option('--cloud', 'file:line: read sessions that ran on another machine from Vibgrate Cloud (shared with `vg review trailer push`)') + .option('--dsn ', 'DSN token for --cloud (or use VIBGRATE_DSN / `vg login`)') + .action(async function (this: Command, pkg: string, opts: { cloud?: boolean; dsn?: string }) { const global = readGlobal(this); const root = rootOf(global); + const at = /^(.+):(\d+)$/.exec(pkg); + if (at && fs.existsSync(path.resolve(root, at[1]))) { + await whyLine(root, at[1], Number(at[2]), Boolean(global.json), opts); + return; + } + if (!(await gitHistoryAvailable(root))) { throw new CliError( 'git history is required for `vg why` (not a git repository, or git is unavailable)', @@ -98,3 +112,65 @@ function severityTag(severity: string): string { return c.dim(severity); } } + +/** + * `vg why `: the commit that last changed the line, and the VG Code + * sessions its Vibgrate-Session trailers name, with what each was asked about + * that file. The agent's answers are its own account, not verified. With + * `--cloud`, a session that is not on this machine is read from Vibgrate + * Cloud, where `vg review trailer push` shared it. + */ +async function whyLine(root: string, file: string, line: number, asJson: boolean, opts: { cloud?: boolean; dsn?: string }): Promise { + if (!isGitRepo(root)) throw new CliError('`vg why ` needs a git repository', ExitCode.USAGE_ERROR); + const top = gitTopLevel(root); + const rel = path.relative(top, path.resolve(root, file)).split(path.sep).join('/'); + const p = lineProvenance(top, rel, line); + const absent = p.sessions.filter((s) => !s.found).map((s) => s.id); + const fromCloud = new Map(); + if (opts.cloud && absent.length > 0) { + for (const s of await lookupProvenance(cloudDsn(opts.dsn), repoIdentity(top), absent)) fromCloud.set(s.id, s); + } + const sessions = p.sessions.map((s) => { + if (s.found) { + return { id: s.id, title: sessionTitle(s.found), model: s.found.model ?? null, turns: turnsTouching(s.found, top, rel), source: 'local' as const }; + } + const cloud = fromCloud.get(s.id); + if (cloud) { + const turns = cloud.turns.filter((t) => t.files.includes(rel)).map((t) => ({ turn: t.turn, asked: t.asked, answered: t.answered })); + return { id: s.id, title: cloud.title, model: cloud.model, turns, source: 'cloud' as const }; + } + return { id: s.id, title: null, model: null, turns: [], source: null }; + }); + if (asJson) { + json({ file: rel, line, commit: p.commit, sessions: sessions.map((s) => ({ ...s, found: s.source !== null })) }); + return; + } + info(`${c.cyan('vg why')} ${c.bold(`${rel}:${line}`)}`); + if (!p.commit) { + info(c.dim(' not committed yet, or git could not blame this line')); + return; + } + info(` ${c.bold(p.commit.sha.slice(0, 12))} ${p.commit.subject} ${c.dim(`${p.commit.author} ${p.commit.date.slice(0, 10)}`)}`); + if (sessions.length === 0) { + info(c.dim(` no ${TRAILER} trailer on this commit — turn it on for future commits with \`vg review trailer on\``)); + return; + } + for (const s of sessions) { + if (s.source === null) { + info( + c.dim( + opts.cloud + ? ` VG Code session ${s.id} is not on this machine and was not shared to Vibgrate Cloud (\`vg review trailer push\` where it ran)` + : ` VG Code session ${s.id} is not on this machine — add --cloud to read it from Vibgrate Cloud if it was shared`, + ), + ); + continue; + } + info(` VG Code session ${c.bold(s.id)}: ${s.title}${s.model ? c.dim(` · ${s.model}`) : ''}${s.source === 'cloud' ? c.dim(' · from Vibgrate Cloud') : ''}`); + for (const t of s.turns) { + info(` turn ${t.turn} asked: ${t.asked.replace(/\s+/g, ' ').slice(0, 300)}`); + if (t.answered) info(c.dim(` the agent's account (unverified): ${t.answered.replace(/\s+/g, ' ').slice(0, 300)}`)); + } + if (s.turns.length === 0) info(c.dim(' no turn in this session lists this file')); + } +} diff --git a/src/engine/artifacts.ts b/src/engine/artifacts.ts index 4ea9120..e6f3b55 100644 --- a/src/engine/artifacts.ts +++ b/src/engine/artifacts.ts @@ -182,6 +182,10 @@ const DEFAULT_GITIGNORE = [ // copies that must never be committed into the repository that hosts them // (code/worktree-session.ts). 'worktrees', + // Saved review documents an agent or a person edited (review/doc-store.ts): + // per-machine working state with its own retention, never part of the + // change they describe. + 'review-docs/', ]; /** Marks a .vibgrate/.gitignore as vg-authored (safe to keep current). */ diff --git a/src/engine/cache.ts b/src/engine/cache.ts index 3c94ac1..1e5a5c2 100644 --- a/src/engine/cache.ts +++ b/src/engine/cache.ts @@ -26,7 +26,8 @@ import type { FileParse } from './types.js'; */ // Bumped to /4: optional mtime+size fingerprint for stat-skip fast path. -const CACHE_VERSION = 'vg-parse-cache/5'; +// /6: RawCall carries `awaited`; /5 parses lack it. +const CACHE_VERSION = 'vg-parse-cache/6'; interface CacheEntry { hash: string; @@ -82,9 +83,11 @@ export function loadCache( if (!opts.disabled && fs.existsSync(file)) { try { const loaded = JSON.parse(fs.readFileSync(file, 'utf8')) as CacheFile; - // Accept v3 → v4 (stat fields optional). + // Only the current version: an older parse lacks fields the graph now + // carries (`awaited`), and reusing it would make a warm build differ + // from a cold one. if ( - (loaded.version === CACHE_VERSION || loaded.version === 'vg-parse-cache/3') && + loaded.version === CACHE_VERSION && loaded.toolVersion === opts.toolVersion && loaded.grammars === opts.grammars && loaded.entries diff --git a/src/engine/cas.ts b/src/engine/cas.ts index a7ab4ee..78f750c 100644 --- a/src/engine/cas.ts +++ b/src/engine/cas.ts @@ -45,7 +45,8 @@ import type { FileParse } from './types.js'; */ /** Envelope schema for a stored parse; bump when the record layout changes. */ -export const CAS_PARSE_SCHEMA = 'vg-cas-parse/1'; +// /2: RawCall carries `awaited`; /1 parses lack it. +export const CAS_PARSE_SCHEMA = 'vg-cas-parse/2'; /** Manifest schema. */ export const MANIFEST_SCHEMA = 'vg-manifest/1'; /** Embed-text version the vector objects were computed over (see embeddings.ts). */ diff --git a/src/engine/edge-sites.ts b/src/engine/edge-sites.ts new file mode 100644 index 0000000..8968aa4 --- /dev/null +++ b/src/engine/edge-sites.ts @@ -0,0 +1,25 @@ +import type { GraphEdge } from '../schema.js'; + +/** + * Call-site lines on `call` edges. + * + * An edge records *that* a caller reaches a callee; a review diagram also needs + * *where*, so a call-stack frame can pin its call site. Each resolver rung + * records the 1-based line of every call it resolves, in the caller's file. + * + * The set is kept sorted, de-duplicated and capped at the smallest + * {@link MAX_EDGE_SITES} lines, so the stored value depends only on which + * lines exist, never on the order the resolver visited them. + */ +export const MAX_EDGE_SITES = 8; + +export function addEdgeSite(edge: GraphEdge, line: number): void { + if (edge.kind !== 'call' || !Number.isInteger(line) || line < 1) return; + const sites = edge.sites ?? []; + if (sites.includes(line)) return; + if (sites.length >= MAX_EDGE_SITES && line > sites[sites.length - 1]) return; + sites.push(line); + sites.sort((a, b) => a - b); + if (sites.length > MAX_EDGE_SITES) sites.length = MAX_EDGE_SITES; + edge.sites = sites; +} diff --git a/src/engine/haile/haile-provider.ts b/src/engine/haile/haile-provider.ts index 40b9f38..3e6114b 100644 --- a/src/engine/haile/haile-provider.ts +++ b/src/engine/haile/haile-provider.ts @@ -73,6 +73,20 @@ export interface HaileProvider { expand?: boolean; }, ): unknown; + /** + * Review-document diagrams (`vg review doc`): the module chooses the call + * path, flows and signature changes from a trimmed code map and returns + * `vg.review.doc.v1` blocks. Absent on older modules — no diagrams, and the + * document says how to get them. `null` when the module abstained. + */ + reviewDiagrams?(input: unknown): { blocks: unknown[]; contract: unknown[]; notes: string[]; fold?: { path: string; text: string }[] } | null; + /** + * Diff groups (`vg review groups`, the review document): the module places + * each changed file from host facts and git signals and returns `{ groups }` + * in reading order. Absent on older modules — the change is one "not + * grouped" group. `null` when the module abstained. + */ + reviewGroups?(input: unknown): { groups: unknown[] } | null; /** Full map HTML. Absent → the host serves a tiny package list. */ renderArchPage?(opts: { host: 'browser' | 'vscode'; diff --git a/src/engine/parse.ts b/src/engine/parse.ts index 19e2fdd..88c1839 100644 --- a/src/engine/parse.ts +++ b/src/engine/parse.ts @@ -78,6 +78,25 @@ const MEMBER_PARENT_TYPES = new Set([ * resolver needs this bit to know a same-file def with the same short name is * NOT evidence — the receiver points elsewhere (see resolve.ts). */ +/** + * Is the call this callee belongs to awaited? Climb from the callee to the + * nearest call node (a few levels: `obj.method` sits under a member node), + * then ask whether its parent is an await (`await_expression` in JS/TS, C# + * and Rust's `f().await`; `await` in Python). + */ +const CALL_NODE = /(^|_)(call|invocation)(_expression)?$|^call$|method_invocation|invocation_expression/; +function isAwaitedCall(callee: Node): boolean { + let n: Node | null = callee; + for (let i = 0; i < 4 && n; i++) { + if (CALL_NODE.test(n.type)) { + const parent = n.parent; + return parent !== null && (parent.type === 'await_expression' || parent.type === 'await'); + } + n = n.parent; + } + return false; +} + function isQualifiedCallee(node: Node): boolean { const parent = node.parent; if (!parent) return false; @@ -329,11 +348,13 @@ export async function parseSource( if (cap.name !== 'callee') continue; if (defNameBytes.has(cap.node.startIndex)) continue; calleeCaptures.push(cap.node); + const awaited = isAwaitedCall(cap.node); calls.push({ callee: cap.node.text, byte: cap.node.startIndex, line: cap.node.startPosition.row + 1, qualified: isQualifiedCallee(cap.node), + ...(awaited ? { awaited: true } : {}), }); } } diff --git a/src/engine/paths.ts b/src/engine/paths.ts index 6a7dd44..3e87ee2 100644 --- a/src/engine/paths.ts +++ b/src/engine/paths.ts @@ -1,7 +1,7 @@ import { bidirectional } from 'graphology-shortest-path/unweighted.js'; import { buildGraphologyGraph } from './graph-model.js'; import { indexFor } from './relations.js'; -import type { VgGraph } from '../schema.js'; +import type { GraphEdge, VgGraph } from '../schema.js'; /** * Shortest connection between two nodes (`vg path`). Uses graphology's @@ -83,3 +83,115 @@ function neighborhood( function uniqueNames(names: string[]): string[] { return [...new Set(names)].sort((a, b) => a.localeCompare(b)).slice(0, NEIGHBOR_CAP); } + +/** One step of a path: the edge that joins two consecutive nodes. */ +export interface PathHop { + from: string; + to: string; + /** The joining edge's kind; for a reverse path, the edge runs to → from. */ + kind: string; + resolution: string; + confidence: number; + /** First call-site line in the caller's file, for call edges that record one. */ + line?: number; + /** The caller's file, so `line` can be opened. */ + file?: string; + awaited?: boolean; +} + +/** Relational kinds first: when two nodes are joined twice, the call explains more than the import. */ +const HOP_KIND_ORDER = ['call', 'references', 'extends', 'implements', 'import', 'test', 'coverage', 'contains']; + +function hopKindRank(kind: string): number { + const i = HOP_KIND_ORDER.indexOf(kind); + return i < 0 ? HOP_KIND_ORDER.length : i; +} + +/** + * Describe each hop of a path with the edge that joins it. For a forward path + * the edge runs a → b; for a reverse path (the dependency arrow points back) + * it runs b → a. + */ +export function describeHops(graph: VgGraph, ids: string[], direction: PathResult['direction']): PathHop[] { + const byPair = new Map(); + for (const e of graph.edges) { + const k = `${e.src}\0${e.dst}`; + const list = byPair.get(k); + if (list) list.push(e); + else byPair.set(k, [e]); + } + const nodeById = new Map(graph.nodes.map((n) => [n.id, n] as const)); + const hops: PathHop[] = []; + for (let i = 0; i + 1 < ids.length; i++) { + const [a, b] = direction === 'forward' ? [ids[i], ids[i + 1]] : [ids[i + 1], ids[i]]; + const edges = [...(byPair.get(`${a}\0${b}`) ?? [])].sort((x, y) => hopKindRank(x.kind) - hopKindRank(y.kind)); + const e = edges[0]; + const from = nodeById.get(ids[i])?.qualifiedName ?? ids[i]; + const to = nodeById.get(ids[i + 1])?.qualifiedName ?? ids[i + 1]; + if (!e) { + hops.push({ from, to, kind: 'unknown', resolution: 'heuristic', confidence: 0 }); + continue; + } + const hop: PathHop = { from, to, kind: e.kind, resolution: e.resolution, confidence: e.confidence }; + if (e.sites?.length) { + hop.line = e.sites[0]; + hop.file = nodeById.get(e.src)?.file; + } + if (e.awaited) hop.awaited = true; + hops.push(hop); + } + return hops; +} + +/** + * Shortest path that follows `call` edges only — what actually runs, not what + * merely imports or contains what. Breadth-first and deterministic: at equal + * depth, precise resolution beats a name match, then node id. Falls back to + * the reverse direction like {@link shortestPath}. + */ +export function callPath(graph: VgGraph, srcId: string, dstId: string): PathResult | null { + const nodeIds = new Set(graph.nodes.map((n) => n.id)); + if (!nodeIds.has(srcId) || !nodeIds.has(dstId)) return null; + const rank: Record = { scip: 0, tsc: 1, stackgraph: 2, heuristic: 3 }; + const out = new Map(); + for (const e of graph.edges) { + if (e.kind !== 'call') continue; + const list = out.get(e.src); + if (list) list.push(e); + else out.set(e.src, [e]); + } + for (const list of out.values()) { + list.sort((x, y) => (rank[x.resolution] ?? 9) - (rank[y.resolution] ?? 9) || (x.dst < y.dst ? -1 : x.dst > y.dst ? 1 : 0)); + } + const bfs = (from: string, to: string): string[] | null => { + const prev = new Map([[from, null]]); + let frontier = [from]; + while (frontier.length > 0) { + const next: string[] = []; + for (const id of frontier) { + for (const e of out.get(id) ?? []) { + if (prev.has(e.dst)) continue; + prev.set(e.dst, id); + if (e.dst === to) { + const path = [to]; + let cur: string | null | undefined = id; + while (cur) { + path.push(cur); + cur = prev.get(cur); + } + return path.reverse(); + } + next.push(e.dst); + } + } + frontier = next; + } + return null; + }; + if (srcId === dstId) return { ids: [srcId], direction: 'forward' }; + const forward = bfs(srcId, dstId); + if (forward) return { ids: forward, direction: 'forward' }; + const reverse = bfs(dstId, srcId); + if (reverse) return { ids: reverse.slice().reverse(), direction: 'reverse' }; + return null; +} diff --git a/src/engine/resolve.ts b/src/engine/resolve.ts index 6d1c968..a2f1fe0 100644 --- a/src/engine/resolve.ts +++ b/src/engine/resolve.ts @@ -2,6 +2,7 @@ import * as path from 'node:path'; import { nodeId, edgeId } from './ids.js'; import { relativeResolver, type ModuleResolver } from './module-resolver.js'; import { isTestFile } from './tests.js'; +import { addEdgeSite } from './edge-sites.js'; import type { FileParse } from './types.js'; import type { DutyCandidate } from './duties.js'; import type { EdgeKind, GraphEdge, GraphNode, NodeKind, ResolverKind } from '../schema.js'; @@ -245,7 +246,7 @@ export function resolve(parses: FileParse[], resolver?: ModuleResolver): Resolve const srcId = enclosingDefId(localDefs, call.byte) ?? fileId; const resolved = resolveCall(call, p.rel, p.lang, imported, defsByName, srcId, testCaller, nsReach, superTypesByType, modReach); if (resolved) { - edges.add('call', srcId, resolved.id, 'heuristic', resolved.confidence); + edges.add('call', srcId, resolved.id, 'heuristic', resolved.confidence, call.line, call.awaited); stats.callsResolved++; } else { stats.callsUnresolved++; @@ -591,16 +592,21 @@ function resolveType( class EdgeSet { private map = new Map(); - add(kind: EdgeKind, src: string, dst: string, resolution: ResolverKind, confidence: number): void { + add(kind: EdgeKind, src: string, dst: string, resolution: ResolverKind, confidence: number, line?: number, awaited?: boolean): void { const id = edgeId(kind, src, dst); const existing = this.map.get(id); if (existing) { existing.count = (existing.count ?? 1) + 1; // Keep the highest confidence seen for this logical edge. if (confidence > existing.confidence) existing.confidence = confidence; + if (line !== undefined) addEdgeSite(existing, line); + if (awaited && kind === 'call') existing.awaited = true; return; } - this.map.set(id, { id, kind, src, dst, resolution, confidence, count: 1 }); + const edge: GraphEdge = { id, kind, src, dst, resolution, confidence, count: 1 }; + if (line !== undefined) addEdgeSite(edge, line); + if (awaited && kind === 'call') edge.awaited = true; + this.map.set(id, edge); } toArray(): GraphEdge[] { diff --git a/src/engine/scip.ts b/src/engine/scip.ts index 5912810..0ab22e9 100644 --- a/src/engine/scip.ts +++ b/src/engine/scip.ts @@ -14,6 +14,7 @@ import type { EdgeKind, GraphEdge, GraphNode, ResolverKind } from '../schema.js'; import { edgeId } from './ids.js'; +import { addEdgeSite } from './edge-sites.js'; // SCIP SymbolRole bitmask (scip.proto). const ROLE_DEFINITION = 0x1; @@ -193,7 +194,7 @@ export function scipEdges(index: ScipIndex, nodes: GraphNode[], relForScip: (p: const src = enclosing(fileNodes, line); if (!src || src.id === target.id) continue; const kind: EdgeKind = target.kind === 'function' || target.kind === 'method' ? 'call' : 'references'; - add(edgeMap, kind, src.id, target.id); + add(edgeMap, kind, src.id, target.id, line); resolved++; } } @@ -205,15 +206,18 @@ export function scipEdges(index: ScipIndex, nodes: GraphNode[], relForScip: (p: }; } -function add(map: Map, kind: EdgeKind, src: string, dst: string): void { +function add(map: Map, kind: EdgeKind, src: string, dst: string, line?: number): void { const id = edgeId(kind, src, dst); const existing = map.get(id); if (existing) { existing.count = (existing.count ?? 1) + 1; + if (line !== undefined) addEdgeSite(existing, line); return; } const resolution: ResolverKind = 'scip'; - map.set(id, { id, kind, src, dst, resolution, confidence: 1.0, count: 1 }); + const edge: GraphEdge = { id, kind, src, dst, resolution, confidence: 1.0, count: 1 }; + if (line !== undefined) addEdgeSite(edge, line); + map.set(id, edge); } /** Smallest node whose span contains `line`. */ diff --git a/src/engine/ts-resolver.ts b/src/engine/ts-resolver.ts index 5d719bb..2524d97 100644 --- a/src/engine/ts-resolver.ts +++ b/src/engine/ts-resolver.ts @@ -1,6 +1,7 @@ import * as path from 'node:path'; import ts from 'typescript'; import { edgeId } from './ids.js'; +import { addEdgeSite } from './edge-sites.js'; import type { EdgeKind, GraphEdge, GraphNode, NodeKind, ResolverKind } from '../schema.js'; /** @@ -31,7 +32,7 @@ export interface TsFilePartial { /** The program produced a SourceFile for this file (tsc is authoritative). */ covered: boolean; edges: GraphEdge[]; - interfaceCalls: Array<{ srcId: string; interfaceId: string; method: string }>; + interfaceCalls: Array<{ srcId: string; interfaceId: string; method: string; line: number }>; stats: { calls: number; jsx: number; heritage: number; resolved: number }; } @@ -107,7 +108,7 @@ export function tsWalkFiles( // got a call edge to the concrete implementation. Record such calls here; // the cross-file bridge runs in assembleTsResult once ALL covered files' // `implements` edges are known. - const interfaceCalls: Array<{ srcId: string; interfaceId: string; method: string }> = []; + const interfaceCalls: Array<{ srcId: string; interfaceId: string; method: string; line: number }> = []; const fileNodes = nodesByFile.get(file.rel) ?? []; const fileNode = fileNodeByRel.get(file.rel); @@ -123,13 +124,13 @@ export function tsWalkFiles( // centrality see the same call granularity. const src = enclosing(fileNodes, lineOf(node.getStart(sf))) ?? fileNode; if (src && src.id !== target.id) { - add(edges, callKind(target), src.id, target.id); + add(edges, callKind(target), src.id, target.id, lineOf(node.getStart(sf)), ts.isAwaitExpression(node.parent)); stats.resolved++; // Interface-typed method call (`svc.method()` where svc: IFoo): the // target is the interface node, and the method name is the accessed // property. Record it for the single-implementation bridge below. if (target.kind === 'interface' && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { - interfaceCalls.push({ srcId: src.id, interfaceId: target.id, method: node.expression.name.text }); + interfaceCalls.push({ srcId: src.id, interfaceId: target.id, method: node.expression.name.text, line: lineOf(node.getStart(sf)) }); } } } @@ -144,7 +145,7 @@ export function tsWalkFiles( if (target) { const src = enclosing(fileNodes, lineOf(node.getStart(sf))) ?? fileNode; if (src && src.id !== target.id) { - add(edges, callKind(target), src.id, target.id); + add(edges, callKind(target), src.id, target.id, lineOf(node.getStart(sf))); stats.resolved++; } } @@ -206,7 +207,7 @@ export function assembleTsResult( const edges = new Map(); const covered = new Set(); const stats = { files: 0, calls: 0, jsx: 0, heritage: 0, resolved: 0 }; - const interfaceCalls: Array<{ srcId: string; interfaceId: string; method: string }> = []; + const interfaceCalls: Array<{ srcId: string; interfaceId: string; method: string; line: number }> = []; for (const rel of orderedRels) { const p = partials.get(rel); if (!p || !p.covered) continue; @@ -230,7 +231,7 @@ export function assembleTsResult( if (list) list.push(e.src); else implsByInterface.set(e.dst, [e.src]); } - for (const { srcId, interfaceId, method } of interfaceCalls) { + for (const { srcId, interfaceId, method, line } of interfaceCalls) { const impls = implsByInterface.get(interfaceId); if (!impls || impls.length !== 1) continue; const impl = byId.get(impls[0]); @@ -239,7 +240,7 @@ export function assembleTsResult( (n) => (n.kind === 'method' || n.kind === 'function') && n.qualifiedName === `${impl.qualifiedName}.${method}`, ); if (target && target.id !== srcId) { - add(edges, 'call', srcId, target.id); + add(edges, 'call', srcId, target.id, line); stats.resolved++; } } @@ -388,14 +389,19 @@ function compilerOptions(root: string): ts.CompilerOptions { return base; } -function add(map: Map, kind: EdgeKind, src: string, dst: string): void { +function add(map: Map, kind: EdgeKind, src: string, dst: string, line?: number, awaited?: boolean): void { const id = edgeId(kind, src, dst); const existing = map.get(id); if (existing) { existing.count = (existing.count ?? 1) + 1; + if (line !== undefined) addEdgeSite(existing, line); + if (awaited && kind === 'call') existing.awaited = true; return; } - map.set(id, { id, kind, src, dst, resolution: PRECISE_KIND, confidence: 1.0, count: 1 }); + const edge: GraphEdge = { id, kind, src, dst, resolution: PRECISE_KIND, confidence: 1.0, count: 1 }; + if (line !== undefined) addEdgeSite(edge, line); + if (awaited && kind === 'call') edge.awaited = true; + map.set(id, edge); } function enclosing(nodes: GraphNode[], line: number): GraphNode | undefined { diff --git a/src/engine/tsc-cache.ts b/src/engine/tsc-cache.ts index 6be11da..cad72e2 100644 --- a/src/engine/tsc-cache.ts +++ b/src/engine/tsc-cache.ts @@ -24,7 +24,8 @@ import type { TsFilePartial } from './ts-resolver.js'; */ // /1: initial version. -const TSC_CACHE_VERSION = 'vg-tsc-cache/1'; +// /3: call edges carry `sites` and `awaited`; older partials lack them. +const TSC_CACHE_VERSION = 'vg-tsc-cache/3'; const AMBIENT_RE = /\bdeclare\s+(global|module)\b/; diff --git a/src/engine/types.ts b/src/engine/types.ts index d8e8e44..3e7706c 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -30,6 +30,8 @@ export interface RawCall { line: number; // 1-based /** True when the call site had a receiver/qualifier (`obj.foo()`, `pkg::foo()`); the receiver itself is not captured. */ qualified?: boolean; + /** True when the call expression is awaited (`await f()`, `f().await`). */ + awaited?: boolean; } export interface RawImport { diff --git a/src/mcp/review-tools.test.ts b/src/mcp/review-tools.test.ts new file mode 100644 index 0000000..4b6828f --- /dev/null +++ b/src/mcp/review-tools.test.ts @@ -0,0 +1,152 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { VgGraph } from '../schema.js'; +import { REVIEW_TOOLS } from './review-tools.js'; +import { extraToolsFor } from './server.js'; +import { HOT_TOOLS, TOOLS } from './tools.js'; + +/** + * `review_doc`: listed only on request, honest about what it writes, and a + * thin adapter over the saved-document store — driven here the way an agent + * drives it, against a real git repository. + */ + +const names = (opts: Parameters[0]) => extraToolsFor(opts, '/repo').map((t) => t.name); +const tool = REVIEW_TOOLS[0]; +const call = (root: string, args: Record) => + Promise.resolve(tool.handler({} as VgGraph, args, { root })) as Promise>; + +describe('what `vg serve` advertises', () => { + it('a default `vg serve` lists no review tool; `--review` adds exactly one', () => { + expect(names({})).toEqual([]); + expect(names({ compressTools: true, memory: true })).not.toContain('review_doc'); + expect(names({ review: true })).toEqual(['review_doc']); + }); + + it('stays out of the hot core and never shadows a code-map tool', () => { + expect(HOT_TOOLS).not.toContain('review_doc' as never); + expect(TOOLS.map((t) => t.name)).not.toContain('review_doc'); + }); + + it('declares that it writes, that what it writes is never destroyed, and that comments reach Cloud', () => { + expect(tool.annotations).toEqual({ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }); + expect(tool.description).toMatch(/op "reply" answers a thread/); + expect(tool.graphless).toBe(true); + expect(tool.description).toMatch(/write what and why first/); + expect(tool.description).toMatch(/recorded as origin "agent"/); + }); +}); + +describe('review_doc, as an agent uses it', () => { + const roots: string[] = []; + afterEach(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); + }); + const git = (cwd: string, ...args: string[]) => { + const res = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (res.status !== 0) throw new Error(`git ${args.join(' ')}: ${res.stderr}`); + }; + function repo(): string { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'vg-review-tool-'))); + roots.push(root); + git(root, 'init', '-q', '-b', 'main'); + git(root, 'config', 'user.email', 'test@example.com'); + git(root, 'config', 'user.name', 'Test'); + fs.mkdirSync(path.join(root, 'src')); + fs.writeFileSync(path.join(root, '.gitignore'), '.vibgrate/\n'); + fs.writeFileSync(path.join(root, 'src/a.ts'), 'export const a = 1;\nexport const b = 2;\n'); + git(root, 'add', '-A'); + git(root, 'commit', '-q', '-m', 'base'); + fs.writeFileSync(path.join(root, 'src/a.ts'), 'export const a = 1;\nexport const b = validate(2);\n'); + return root; + } + + it('reads the comments on the pushed copy and answers one, with the DSN, naming the same change', async () => { + const root = repo(); + const opened = await call(root, { op: 'open' }); + const id = opened.doc_id as string; + const sent: { url: string; body: Record }[] = []; + vi.stubEnv('VIBGRATE_DSN', 'vibgrate+https://key_1:s3cret@us.ingest.vibgrate.com/ws_1'); + vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)) as Record; + sent.push({ url, body }); + if (url.endsWith('/comments')) { + return new Response( + JSON.stringify({ + status: 'ok', + title: 'Review', + comments: [ + { id: 'rdc_1', blockId: 'blk_1', blockTitle: 'Design', parentId: null, authorKind: 'person', authorName: 'Ada', body: 'Why?', createdAt: 't', resolvedAt: null }, + { id: 'rdc_2', blockId: 'blk_2', blockTitle: null, parentId: null, authorKind: 'person', authorName: 'Bo', body: 'Done', createdAt: 't', resolvedAt: 't' }, + ], + }), + { status: 200 }, + ); + } + return new Response(JSON.stringify({ status: 'ok', comment: { id: 'rdc_3', parentId: body.parent_id, authorKind: 'agent', authorName: 'Agent (review_doc)', body: body.body } }), { status: 201 }); + }); + try { + const listed = await call(root, { op: 'comments', doc_id: id }); + expect(listed).toMatchObject({ doc_id: id, resolved: 1, open: [{ id: 'rdc_1', comments: [{ authorName: 'Ada', body: 'Why?' }] }] }); + const replied = await call(root, { op: 'reply', doc_id: id, comment_id: 'rdc_1', text: 'Because the base side was not built.' }); + expect(replied).toMatchObject({ replied: true, comment: { authorKind: 'agent' } }); + expect(sent.map((s) => s.url)).toEqual([ + 'https://us.ingest.vibgrate.com/v1/ingest/review-doc/comments', + 'https://us.ingest.vibgrate.com/v1/ingest/review-doc/reply', + ]); + const doc = (opened.doc ?? {}) as { target: { repo_key: string; head_sha: string; base_sha: string } }; + expect(sent[1].body).toMatchObject({ repo_key: doc.target.repo_key, head_sha: doc.target.head_sha, base_sha: doc.target.base_sha, parent_id: 'rdc_1' }); + expect(await call(root, { op: 'reply', doc_id: id, comment_id: 'rdc_1' })).toMatchObject({ error: 'bad_request' }); + } finally { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + } + }); + + it('open → write what and why → check → history → restore', async () => { + const root = repo(); + const opened = await call(root, { op: 'open' }); + expect(opened).toMatchObject({ version: 1, built: true }); + const id = opened.doc_id as string; + const outline = opened.outline as { sections: { kind: string; blocks: { id: string }[] }[] }; + const first = outline.sections[0].blocks[0].id; + + const patched = await call(root, { + op: 'patch', + doc_id: id, + version: 1, + ops: [{ op: 'set_text', block: first, text: 'Validates `b` before export — [the change](head:src/a.ts#L2).' }], + }); + expect(patched).toMatchObject({ saved: true, version: 2 }); + + const block = await call(root, { op: 'get', doc_id: id, block: first }); + expect(block).toMatchObject({ section: 'what_why', block: { origin: 'agent' } }); + + expect(await call(root, { op: 'check', doc_id: id })).toEqual({ doc_id: id, version: 2, valid: true, issues: [] }); + const history = (await call(root, { op: 'history', doc_id: id })) as { versions: { version: number; by: string }[] }; + expect(history.versions.map((v) => [v.version, v.by])).toEqual([[2, 'agent'], [1, 'vg']]); + expect(await call(root, { op: 'restore', doc_id: id, version: 1 })).toMatchObject({ saved: true, version: 3 }); + }); + + it('says what is wrong instead of saving a bad patch', async () => { + const root = repo(); + const { doc_id } = await call(root, { op: 'open' }); + expect(await call(root, { op: 'patch', doc_id, ops: [] })).toEqual({ error: 'bad_request', message: 'patch needs version: the version you read' }); + const stale = await call(root, { op: 'patch', doc_id, version: 7, ops: [{ op: 'set_title', title: 'x' }] }); + expect(stale).toMatchObject({ saved: false, conflict: true, version: 1 }); + expect(await call(root, { op: 'rewrite', doc_id })).toMatchObject({ error: 'bad_request' }); + expect(await call(root, { op: 'get' })).toMatchObject({ error: 'bad_request', message: 'op "get" needs doc_id — call op "open" first' }); + expect(await call(root, { op: 'get', doc_id: 'rd_000000000000' })).toMatchObject({ error: 'review_doc_failed' }); + }); + + it('works outside a git repository only to say so', async () => { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'vg-review-tool-nogit-'))); + roots.push(dir); + const res = await call(dir, { op: 'open' }); + expect(res).toMatchObject({ error: 'review_doc_failed' }); + expect(String(res.message)).toMatch(/needs a git repository/); + }); +}); diff --git a/src/mcp/review-tools.ts b/src/mcp/review-tools.ts new file mode 100644 index 0000000..ef017d8 --- /dev/null +++ b/src/mcp/review-tools.ts @@ -0,0 +1,180 @@ +/** + * `review_doc` — an agent writes the review document for a change, over MCP. + * + * One tool with an `op` argument, listed only under `vg serve --review` + * (`VG_REVIEW=1`): a default `vg serve` lists exactly the tools it did before, + * because every listed schema is billed on every agent step + * (IDE-INTEGRATION-PLAN §2, FEATURE-DESIGN-PRINCIPLES P2). + * + * A thin adapter: every rule (one document per scope, atomic patches, version + * checks, provenance that cannot be forged, pins checked against the change, + * retention) lives in `review/doc-store.ts`, shared with `vg review doc`, so + * the CLI and the tool cannot disagree. + * + * Annotations: it writes only Vibgrate's own state (`.vibgrate/review-docs/`), + * never the repository, and every write adds a version rather than replacing + * one, so nothing an agent does here can lose work: `readOnlyHint: false`, + * `destructiveHint: false`, as for `compress_content` and `memory_save`. + * `openWorldHint: true` because ops `comments` and `reply` read and answer + * the comments people left on the pushed document in Vibgrate Cloud. + */ + +import type { DocScope } from '../review/doc-build.js'; +import type { ReviewDoc } from '../review/doc.js'; +import { + checkDocument, + documentHistory, + getDocument, + MAX_OPS, + openDocument, + outline, + patchDocument, + restoreVersion, +} from '../review/doc-store.js'; +import type { VgTool } from './tools.js'; +import { cloudDsn, fetchComments, replyToComment, targetOf } from '../review/doc-comments.js'; + +/** Inline the whole document only below this size; above it the outline plus `get` by block keeps every result inside the token budget. */ +const INLINE_DOC_CHARS = 60_000; + +const DESCRIPTION = [ + 'Write the review document for a change: what and why, requirements, one primary design diagram, implementation — every claim about code pinned to lines.', + 'Order: (1) op "open" (the change, or `session` for a VG Code chat) and read the outline; (2) write what and why first (set_text on its first block);', + '(3) add requirements from the task; (4) add or correct diagrams — pin every node to code as {side:"head"|"base", path, start, end}, or link text as [label](head:path#L10-L24);', + '(5) op "check" and re-read before you finish.', + 'Patches are all or nothing and name the version they were written against; a pin that does not land, or a stale version, saves nothing and says why.', + 'Whatever you write is recorded as origin "agent"; only unchanged graph-derived elements stay "graph".', + 'After the document is pushed (`vg review doc --push`), op "comments" lists what people asked on its blocks in Vibgrate Cloud, and op "reply" answers a thread (comment_id, text); replies are shown as written by an agent.', +].join(' '); + +const PIN = { + type: 'object', + properties: { + side: { type: 'string', enum: ['base', 'head'] }, + path: { type: 'string' }, + start: { type: 'integer', minimum: 1 }, + end: { type: 'integer', minimum: 1 }, + }, + required: ['side', 'path', 'start', 'end'], +}; + +const SCHEMA = { + type: 'object', + properties: { + op: { type: 'string', enum: ['open', 'get', 'patch', 'check', 'history', 'restore', 'comments', 'reply'] }, + doc_id: { type: 'string', description: 'from open (rd_…); required for every op but open' }, + base: { type: 'string', description: 'open: review HEAD against the merge-base with this ref (default: working tree vs HEAD)' }, + in_place: { type: 'boolean', description: 'open: with base, include the working tree' }, + session: { type: 'string', description: 'open: a VG Code chat id, or "latest" — only the files it touched, its requests as requirements' }, + fresh: { type: 'boolean', description: 'open: rebuild from the change as a new version instead of reusing the saved one' }, + base_graph: { type: 'boolean', description: 'open: also map the base commit for before/after call paths (slower)' }, + block: { type: 'string', description: 'get: return one block by id' }, + comment_id: { type: 'string', description: 'reply: the comment (rdc_…) to answer, from op "comments"' }, + text: { type: 'string', description: 'reply: your answer, plain text, at most 4000 characters' }, + version: { type: 'integer', minimum: 1, description: 'patch: the version you read (required); get/restore: which version' }, + ops: { + type: 'array', + maxItems: MAX_OPS, + description: + 'patch: [{op:"set_text",block,text} | {op:"insert",section,block,after?|at?:"start"} | {op:"replace",block,with} | {op:"remove",block} | {op:"move",block,section,after?} | {op:"set_primary",block} | {op:"set_title",title}]. section: what_why | requirements | design | implementation. Block types: markdown, callout{tone,text,pins?}, code_peek{pin,caption?}, flow{title,nodes[{key,label,kind?,pins}],edges[{from,to,label?,kind?}]}, sequence{title,actors,steps[{from,to,label,pins|note}]}, call_stack_diff, data_store, system_map, divider.', + items: { type: 'object' }, + }, + }, + required: ['op'], + additionalProperties: false, + $defs: { pin: PIN }, +}; + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v.trim() ? v.trim() : undefined; +} + +/** The document, or only its outline when inlining it would crowd the agent's context. */ +function view(doc_id: string, version: number, doc: ReviewDoc): Record { + const json = JSON.stringify(doc); + return { + doc_id, + version, + outline: outline(doc), + ...(json.length <= INLINE_DOC_CHARS ? { doc } : { doc: null, hint: `the document is ${json.length} characters; read blocks with op "get" and block ` }), + }; +} + +function scopeFrom(args: Record): DocScope { + const session = str(args.session); + const base = str(args.base) ?? null; + return session ? { kind: 'session', session, base } : { kind: 'change', base, in_place: args.in_place === true }; +} + +async function run(root: string, args: Record): Promise { + const op = str(args.op); + if (op === 'open') { + const opened = await openDocument(root, scopeFrom(args), { fresh: args.fresh === true, baseGraph: args.base_graph === true }); + return { ...view(opened.doc_id, opened.version, opened.doc), built: opened.built, ...(opened.reason ? { rebuilt_because: opened.reason } : {}) }; + } + const id = str(args.doc_id); + if (!id) return { error: 'bad_request', message: `op "${op ?? ''}" needs doc_id — call op "open" first` }; + const version = typeof args.version === 'number' ? args.version : undefined; + switch (op) { + case 'get': { + const got = getDocument(root, id, version); + const block = str(args.block); + if (!block) return view(got.doc_id, got.version, got.doc); + for (const s of got.doc.sections) { + const b = s.blocks.find((x) => x.id === block); + if (b) return { doc_id: id, version: got.version, section: s.kind, block: b }; + } + return { error: 'not_found', message: `no block ${block} in version ${got.version}`, outline: outline(got.doc) }; + } + case 'patch': { + if (version === undefined) return { error: 'bad_request', message: 'patch needs version: the version you read' }; + const res = patchDocument(root, id, version, args.ops); + if (!res.ok) return { saved: false, ...res }; + return { saved: true, notes: res.notes, ...view(res.doc_id, res.version, res.doc) }; + } + case 'check': + return checkDocument(root, id); + case 'history': + return documentHistory(root, id); + case 'restore': { + if (version === undefined) return { error: 'bad_request', message: 'restore needs version' }; + const res = restoreVersion(root, id, version); + if (!res.ok) return { saved: false, ...res }; + return { saved: true, ...view(res.doc_id, res.version, res.doc) }; + } + case 'comments': { + // The pushed copy of this document: same repository, head and base. + const { doc } = getDocument(root, id); + const { title, threads } = await fetchComments(cloudDsn(), targetOf(doc)); + return { doc_id: id, title, open: threads.filter((t) => !t.resolved), resolved: threads.filter((t) => t.resolved).length }; + } + case 'reply': { + const commentId = str(args.comment_id); + const text = str(args.text); + if (!commentId || !text) return { error: 'bad_request', message: 'reply needs comment_id and text' }; + const { doc } = getDocument(root, id); + return { replied: true, comment: await replyToComment(cloudDsn(), targetOf(doc), commentId, text, 'review_doc') }; + } + default: + return { error: 'bad_request', message: 'op must be one of open, get, patch, check, history, restore, comments, reply' }; + } +} + +export const REVIEW_TOOLS: VgTool[] = [ + { + name: 'review_doc', + description: DESCRIPTION, + inputSchema: SCHEMA, + // It reads the code map itself when one exists, and works without one. + graphless: true, + // comments and reply reach Vibgrate Cloud with the workspace DSN. + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + handler: async (_graph, args, ctx) => { + try { + return await run(ctx.root, args); + } catch (err) { + return { error: 'review_doc_failed', message: (err as Error).message }; + } + }, + }, +]; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b0af0fd..42e0c3e 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -11,6 +11,7 @@ import type { RefreshOutcome, refreshIfStale } from '../engine/refresh.js'; import { RefreshScheduler, REFRESH_BUDGET_MS as SCHEDULER_BUDGET_MS } from '../engine/refresh-scheduler.js'; import { TOOLS, budgetSuffix, listedToolNames, warmEmbedderInBackground, type ToolSurface, type VgTool } from './tools.js'; import { COMPRESS_TOOLS, memoryVgTools } from './compress-tools.js'; +import { REVIEW_TOOLS } from './review-tools.js'; import { isRelevantChange } from '../engine/watch-filter.js'; import { DaemonSemanticSession } from '../runtime/vgd/semantic-client.js'; import { envForNamedVgdSocket } from '../runtime/vgd/attach.js'; @@ -129,6 +130,8 @@ export interface ServeOptions { compressTools?: boolean; /** Cross-agent memory tools (`memory_search`, `memory_save`) — opt-in (`--memory` / `VG_MEMORY=1`). */ memory?: boolean; + /** Review document authoring (`review_doc`) — opt-in (`--review` / `VG_REVIEW=1`). */ + review?: boolean; /** * `vg serve --compress-only`: there is no code map, so list only the tools * that answer without one. The graph tools stay dispatchable and return the @@ -142,12 +145,13 @@ export interface ServeOptions { * registration (FEATURE-DESIGN-PRINCIPLES P2 — every listed schema is a * per-step token tax on every user, so a default `vg serve` must not pay for a * capability it was not asked for): compression under `vg serve --compress`, - * memory under `--memory`. + * memory under `--memory`, review document authoring under `--review`. */ export function extraToolsFor(opts: ServeOptions, root: string): VgTool[] { return [ ...(opts.compressTools === true ? COMPRESS_TOOLS : []), ...(opts.memory ? memoryVgTools(root) : []), + ...(opts.review ? REVIEW_TOOLS : []), ]; } diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 4a45b57..75333fc 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -9,7 +9,7 @@ import { loadTopicTags } from '../engine/relevance-enrich.js'; import { loadEmbedder, getNodeEmbeddings, isModelReady, withTimeout, type Embedder } from '../engine/embeddings.js'; import { resolveOne } from '../engine/lookup.js'; import { indexFor } from '../engine/relations.js'; -import { pathDisconnect, shortestPath } from '../engine/paths.js'; +import { callPath, describeHops, pathDisconnect, shortestPath } from '../engine/paths.js'; import { impactOf } from '../engine/impact.js'; import { coveringTests } from '../engine/test-query.js'; import { loadOrDiscoverFederation } from '../runtime/federation.js'; @@ -448,13 +448,15 @@ export const TOOLS: VgTool[] = [ }, { name: 'find_path', - description: 'Shortest connection from a to b.', + description: + 'Shortest connection from a to b, with the edge kind of each hop. calls_only: follow call edges only (what actually runs), with the call-site line per hop.', inputSchema: obj( { a: { type: 'string' }, b: { type: 'string' }, pick_a: { type: 'number' }, pick_b: { type: 'number' }, + calls_only: { type: 'boolean' }, }, ['a', 'b'], ), @@ -463,10 +465,17 @@ export const TOOLS: VgTool[] = [ const rb = resolveOne(graph, String(args.b ?? ''), numOrU(args.pick_b)); if (!ra.node) return { endpoint: 'a', ...unresolved(ra.candidates) }; if (!rb.node) return { endpoint: 'b', ...unresolved(rb.candidates) }; - const result = shortestPath(graph, ra.node.id, rb.node.id); + const callsOnly = args.calls_only === true; + const result = callsOnly ? callPath(graph, ra.node.id, rb.node.id) : shortestPath(graph, ra.node.id, rb.node.id); if (!result) return pathDisconnect(graph, ra.node.id, rb.node.id); const byId = new Map(graph.nodes.map((n) => [n.id, n] as const)); - return { connected: true, direction: result.direction, path: result.ids.map((id) => byId.get(id)?.qualifiedName ?? id) }; + return { + connected: true, + direction: result.direction, + path: result.ids.map((id) => byId.get(id)?.qualifiedName ?? id), + hops: describeHops(graph, result.ids, result.direction), + ...(callsOnly ? { calls_only: true } : {}), + }; }, }, { diff --git a/src/reporting/commands/init.test.ts b/src/reporting/commands/init.test.ts new file mode 100644 index 0000000..40d7988 --- /dev/null +++ b/src/reporting/commands/init.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { initCommand } from './init.js'; + +describe('vg init', () => { + let dir: string; + let logs: string[]; + let spy: ReturnType; + + afterEach(() => { + spy?.mockRestore(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + + async function run(args: string[]): Promise { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-init-')); + logs = []; + spy = vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]) => { + logs.push(parts.map(String).join(' ')); + }); + await initCommand.parseAsync([dir, ...args], { from: 'user' }); + } + + it('scaffolds .vibgrate/ and vibgrate.config.ts and names the next commands as vg', async () => { + await run(['--yes']); + expect(fs.existsSync(path.join(dir, '.vibgrate'))).toBe(true); + const config = fs.readFileSync(path.join(dir, 'vibgrate.config.ts'), 'utf8'); + expect(config).toContain("from '@vibgrate/cli'"); + expect(config).toContain('eolDays: 180'); + const text = logs.join('\n'); + expect(text).toContain('Created'); + expect(text).toContain('.vibgrate/'); + expect(text).toContain('vibgrate.config.ts'); + expect(text).toContain('vg scan'); + expect(text).toContain('vg baseline'); + expect(text).not.toContain('vibgrate scan'); + expect(text).not.toContain('vibgrate baseline'); + }); + + it('leaves an existing config file in place', async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-init-')); + const configPath = path.join(dir, 'vibgrate.config.ts'); + fs.writeFileSync(configPath, 'export default { thresholds: {} };\n'); + logs = []; + spy = vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]) => { + logs.push(parts.map(String).join(' ')); + }); + await initCommand.parseAsync([dir], { from: 'user' }); + expect(fs.readFileSync(configPath, 'utf8')).toBe('export default { thresholds: {} };\n'); + expect(logs.join('\n')).toMatch(/already exists, skipping/); + expect(fs.existsSync(path.join(dir, '.vibgrate'))).toBe(true); + }); +}); diff --git a/src/reporting/commands/init.ts b/src/reporting/commands/init.ts index a0c6b90..158b1de 100644 --- a/src/reporting/commands/init.ts +++ b/src/reporting/commands/init.ts @@ -31,7 +31,7 @@ export const initCommand = new Command('init') console.log(''); console.log(chalk.bold('Next steps:')); - console.log(` ${chalk.cyan('vibgrate scan')} Scan for upgrade drift`); - console.log(` ${chalk.cyan('vibgrate baseline')} Create a drift baseline`); + console.log(` ${chalk.cyan('vg scan')} Scan for upgrade drift`); + console.log(` ${chalk.cyan('vg baseline')} Create a drift baseline`); console.log(''); }); diff --git a/src/review/base-graph.test.ts b/src/review/base-graph.test.ts new file mode 100644 index 0000000..b9b2e7b --- /dev/null +++ b/src/review/base-graph.test.ts @@ -0,0 +1,71 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { buildBaseGraph } from './base-graph.js'; +import { collectChangeSet, type GitRunner } from './git.js'; + +const run: GitRunner = (args, cwd) => { + const res = spawnSync('git', args, { cwd, encoding: 'utf8' }); + return { stdout: res.stdout ?? '', status: res.status ?? 1 }; +}; +const git = (cwd: string, ...args: string[]) => { + const res = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (res.status !== 0) throw new Error(`git ${args.join(' ')}: ${res.stderr}`); +}; +const write = (root: string, rel: string, text: string) => { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), text); +}; + +describe('buildBaseGraph', () => { + const roots: string[] = []; + afterEach(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); + }); + + function repo(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-base-graph-')); + roots.push(root); + git(root, 'init', '-q', '-b', 'main'); + git(root, 'config', 'user.email', 'test@example.com'); + git(root, 'config', 'user.name', 'Test'); + write(root, 'pkg/src/a.ts', "export function oldName() {\n return 1;\n}\n"); + write(root, 'pkg/src/b.ts', "import { oldName } from './a';\nexport function caller() {\n return oldName();\n}\n"); + git(root, 'add', '-A'); + git(root, 'commit', '-q', '-m', 'base'); + git(root, 'checkout', '-q', '-b', 'feature'); + write(root, 'pkg/src/a.ts', "export function newName() {\n return 2;\n}\n"); + write(root, 'pkg/src/b.ts', "import { newName } from './a';\nexport function caller() {\n return newName();\n}\n"); + git(root, 'commit', '-q', '-am', 'rename'); + return root; + } + + it('maps the base commit in a throwaway worktree and leaves the checkout alone', async () => { + const root = repo(); + const change = collectChangeSet(root, 'main', run); + const res = await buildBaseGraph(change, path.join(root, 'pkg'), run); + expect(res.reason).toBeUndefined(); + const names = res.graph!.nodes.map((n) => n.qualifiedName); + expect(names).toContain('oldName'); + expect(names).not.toContain('newName'); + // Graph paths are relative to the map root, exactly like the head map's. + expect(res.graph!.nodes.find((n) => n.qualifiedName === 'oldName')!.file).toBe('src/a.ts'); + // The checkout still has the change, and no worktree is left behind. + expect(fs.readFileSync(path.join(root, 'pkg/src/a.ts'), 'utf8')).toContain('newName'); + expect(run(['worktree', 'list'], root).stdout.trim().split('\n')).toHaveLength(1); + }, 60_000); + + it('reports, rather than throws, when the map root did not exist at the base', async () => { + const root = repo(); + write(root, 'newpkg/x.ts', 'export const x = 1;\n'); + git(root, 'add', '-A'); + git(root, 'commit', '-q', '-m', 'new package'); + const change = collectChangeSet(root, 'main', run); + const res = await buildBaseGraph(change, path.join(root, 'newpkg'), run); + expect(res.graph).toBeNull(); + expect(res.reason).toMatch(/does not exist at the base commit/); + expect(run(['worktree', 'list'], root).stdout.trim().split('\n')).toHaveLength(1); + }, 60_000); +}); diff --git a/src/review/base-graph.ts b/src/review/base-graph.ts new file mode 100644 index 0000000..1533c3e --- /dev/null +++ b/src/review/base-graph.ts @@ -0,0 +1,50 @@ +/** + * The code map at the change's base commit, for before/after call paths. + * + * Built in a throwaway `git worktree` so the user's checkout is never touched. + * Unchanged files are served from the machine's content-addressed parse store + * (engine/cas.ts), so on a repository that has been mapped before the cost is + * mostly the files that differ and the precise resolver pass. The worktree is + * removed on every path out, including failure. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { buildGraph } from '../engine/build.js'; +import type { VgGraph } from '../schema.js'; +import { defaultRun, type ChangeSet, type GitRunner } from './git.js'; + +export interface BaseGraphResult { + graph: VgGraph | null; + /** Wall time for checkout and build, for the document's notes. */ + ms: number; + /** Why no graph came back, when none did. */ + reason?: string; +} + +export async function buildBaseGraph( + change: ChangeSet, + mapRoot: string, + run: GitRunner = defaultRun, + now: () => number = () => performance.now(), +): Promise { + const t0 = now(); + if (!change.baseSha) return { graph: null, ms: 0, reason: 'the change has no base commit' }; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-review-base-')); + const added = run(['worktree', 'add', '--detach', '--quiet', dir, change.baseSha], change.topLevel); + try { + if (added.status !== 0) return { graph: null, ms: now() - t0, reason: 'git could not check out the base commit' }; + const rel = path.relative(change.topLevel, mapRoot); + const root = rel && rel !== '.' ? path.join(dir, rel) : dir; + if (!fs.existsSync(root)) return { graph: null, ms: now() - t0, reason: `${rel} does not exist at the base commit` }; + const built = await buildGraph({ root, noCoverage: true, noGround: true }); + return { graph: built.graph, ms: now() - t0 }; + } catch (err) { + return { graph: null, ms: now() - t0, reason: `the base build failed: ${(err as Error).message}` }; + } finally { + if (added.status === 0) run(['worktree', 'remove', '--force', dir], change.topLevel); + fs.rmSync(dir, { recursive: true, force: true }); + run(['worktree', 'prune'], change.topLevel); + } +} diff --git a/src/review/data-models.test.ts b/src/review/data-models.test.ts new file mode 100644 index 0000000..ee34f22 --- /dev/null +++ b/src/review/data-models.test.ts @@ -0,0 +1,175 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { extractDataModels, parseEfCore, parsePrisma, parseSqlTables, readDataModels } from './data-models.js'; +import { collectChangeSet, defaultRun } from './git.js'; + +/** + * Data models as declared, with the line of every table, column and key: + * the raw facts the data-store view is derived from. + */ + +const brief = (fields: { key: string; primary_key?: boolean; nullable?: boolean; references?: { collection: string; field: string }; line: number }[]) => + fields.map((f) => `${f.key}${f.primary_key ? '*' : ''}${f.nullable ? '?' : ''}${f.references ? `->${f.references.collection}.${f.references.field}` : ''}@${f.line}`); + +describe('Prisma', () => { + const schema = [ + 'datasource db {', // 1 + ' provider = "postgresql"', // 2 + ' url = env("DATABASE_URL")', // 3 + '}', // 4 + '', // 5 + 'model User {', // 6 + ' id Int @id @default(autoincrement())', // 7 + ' email String @unique', // 8 + ' posts Post[]', // 9 + '}', // 10 + '', // 11 + 'model Post {', // 12 + ' id Int @id', // 13 + ' title String? @map("post_title")', // 14 + ' authorId Int', // 15 + ' author User @relation(fields: [authorId], references: [id])', // 16 + ' @@map("posts")', // 17 + '}', // 18 + ].join('\n'); + + it('reads models, keys, relations and table names, with their lines', () => { + const store = parsePrisma({ path: 'db/schema.prisma', text: schema })!; + expect(store).toMatchObject({ key: 'prisma:db/schema.prisma', label: 'Prisma (postgresql)', storage: 'relational' }); + expect(store.collections.map((c) => [c.key, c.label, c.start, c.end])).toEqual([ + ['User', 'User', 6, 10], + ['Post', 'posts', 12, 18], + ]); + expect(brief(store.collections[0].fields)).toEqual(['id*@7', 'email@8']); + expect(brief(store.collections[1].fields)).toEqual(['id*@13', 'title?@14', 'authorId->User.id@15']); + expect(store.collections[1].fields[1].label).toBe('post_title'); + }); + + it('never reads the connection string', () => { + expect(JSON.stringify(parsePrisma({ path: 's.prisma', text: schema }))).not.toContain('DATABASE_URL'); + }); +}); + +describe('SQL DDL', () => { + it('reads tables, column and table-level keys, with their lines', () => { + const sql = [ + 'CREATE TABLE IF NOT EXISTS "public"."customers" (', // 1 + ' id SERIAL PRIMARY KEY,', // 2 + ' email TEXT NOT NULL', // 3 + ');', // 4 + 'create table orders (', // 5 + ' id int not null,', // 6 + ' customer_id int references customers(id),', // 7 + ' note text,', // 8 + ' primary key (id),', // 9 + ' constraint fk foreign key (customer_id) references customers (id)', // 10 + ');', // 11 + ].join('\n'); + const tables = parseSqlTables({ path: 'db/init.sql', text: sql }); + expect(tables.map((t) => [t.key, t.start, t.end])).toEqual([ + ['customers', 1, 4], + ['orders', 5, 11], + ]); + expect(brief(tables[0].fields)).toEqual(['id*@2', 'email@3']); + expect(brief(tables[1].fields)).toEqual(['id*@6', 'customer_id?->customers.id@7', 'note?@8']); + }); +}); + +describe('EF Core', () => { + const files = [ + { + path: 'src/Infrastructure/AppDbContext.cs', + text: [ + 'using Shop.Domain.Entities;', // 1 + 'namespace Shop.Infrastructure;', // 2 + 'public class AppDbContext : DbContext', // 3 + '{', // 4 + ' public DbSet Products => Set();', // 5 + ' public DbSet Categories { get; set; }', // 6 + '}', // 7 + ].join('\n'), + }, + { + path: 'src/Domain/Entities/Product.cs', + text: [ + 'namespace Shop.Domain.Entities;', // 1 + 'public class Product : BaseEntity', // 2 + '{', // 3 + ' public string Name { get; set; } = "";', // 4 + ' public Guid? CategoryId { get; set; }', // 5 + ' public Category? CategoryNavigation { get; set; }', // 6 + ' public ICollection Related { get; set; } = new List();', // 7 + '}', // 8 + 'public abstract class BaseEntity', // 9 + '{', // 10 + ' [Key]', // 11 + ' public Guid Key { get; set; }', // 12 + '}', // 13 + ].join('\n'), + }, + { path: 'src/Domain/Entities/Category.cs', text: 'namespace Shop.Domain.Entities;\npublic class Category\n{\n public Guid Id { get; set; }\n}\n' }, + // A DTO with the entity's name in another namespace must not be taken for it. + { path: 'src/Api/Dtos.cs', text: 'namespace Shop.Api;\npublic class Product\n{\n public string Wrong { get; set; }\n}\n' }, + ]; + + it('reads every DbSet, the entity it names, its keys and its navigations', () => { + const [store] = parseEfCore(files); + expect(store).toMatchObject({ key: 'efcore:AppDbContext', label: 'AppDbContext', path: 'src/Infrastructure/AppDbContext.cs', line: 3 }); + expect(store.collections.map((c) => [c.key, c.label, c.path, c.start])).toEqual([ + ['Product', 'Products', 'src/Domain/Entities/Product.cs', 2], + ['Category', 'Categories', 'src/Domain/Entities/Category.cs', 2], + ]); + // Own fields, then the base entity's [Key]; navigations and collections are not columns. + expect(brief(store.collections[0].fields)).toEqual(['Name@4', 'CategoryId?->Category.Id@5', 'Key*@12']); + expect(brief(store.collections[1].fields)).toEqual(['Id*@4']); + }); + + it('finds nothing where no context declares a DbSet', () => { + expect(parseEfCore([files[1], files[2]])).toEqual([]); + }); +}); + +describe('readDataModels', () => { + const roots: string[] = []; + afterEach(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); + }); + const git = (cwd: string, ...args: string[]) => { + const res = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (res.status !== 0) throw new Error(`git ${args.join(' ')}: ${res.stderr}`); + }; + + it('reads the reviewed side: the working tree in place, the head commit otherwise', () => { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'vg-data-models-'))); + roots.push(root); + git(root, 'init', '-q', '-b', 'main'); + git(root, 'config', 'user.email', 't@e.st'); + git(root, 'config', 'user.name', 'T'); + fs.mkdirSync(path.join(root, 'db')); + fs.writeFileSync(path.join(root, 'db/schema.sql'), 'CREATE TABLE a (id int primary key);\n'); + git(root, 'add', '-A'); + git(root, 'commit', '-q', '-m', 'base'); + fs.writeFileSync(path.join(root, 'db/schema.sql'), 'CREATE TABLE a (id int primary key);\nCREATE TABLE b (id int primary key);\n'); + const change = collectChangeSet(root, undefined, defaultRun); + const tree = readDataModels(change, {}); + expect(tree[0].collections.map((c) => c.key)).toEqual(['a', 'b']); + const committed = readDataModels(change, { base: 'HEAD' }); + expect(committed[0].collections.map((c) => c.key)).toEqual(['a']); + }); +}); + +describe('extractDataModels', () => { + it('merges SQL across files (the latest declaration wins) and sorts stores', () => { + const stores = extractDataModels([ + { path: 'migrations/002.sql', text: 'CREATE TABLE users (id int primary key, email text not null);' }, + { path: 'migrations/001.sql', text: 'CREATE TABLE users (id int primary key);' }, + { path: 'x.prisma', text: 'model A {\n id Int @id\n}\n' }, + ]); + expect(stores.map((s) => s.key)).toEqual(['prisma:x.prisma', 'sql']); + expect(brief(stores[1].collections[0].fields)).toEqual(['id*@1', 'email@1']); + expect(stores[1].collections[0].path).toBe('migrations/002.sql'); + }); +}); diff --git a/src/review/data-models.ts b/src/review/data-models.ts new file mode 100644 index 0000000..b0082ad --- /dev/null +++ b/src/review/data-models.ts @@ -0,0 +1,481 @@ +/** + * Data models declared in a repository, with the line each table, column and + * key is declared on — the facts behind the review document's data-store view. + * + * Raw facts only, and public: which tables a schema file declares, their + * fields, primary keys and foreign keys. Deciding which of them a change + * touches, which reads and writes to show and which entry points are the use + * cases is the Architecture module's job (`vg_review_diagrams`), not this + * file's. + * + * Sources, each parsed from the file text as it is on the reviewed side: + * + * - Prisma (`*.prisma`): `model` blocks, `@id`, `@@map`, and `@relation(fields, + * references)` turned into a foreign key on the scalar field. + * - SQL DDL (`*.sql`): `CREATE TABLE`, column and table-level `PRIMARY KEY`, + * `REFERENCES` and `FOREIGN KEY`. + * - EF Core (`*.cs`): every `DbSet` on a context class, and the auto + * properties of `T` (and its base classes): `[Key]`, `Id` / `TId` keys, + * and `XId` beside a navigation `X` as a foreign key. + * + * Credentials are never read: Prisma `datasource` blocks yield only the + * provider name, and SQL files are read for `CREATE TABLE` statements alone. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { defaultRun, type ChangeSet, type GitRunner } from './git.js'; +import { headIsWorkingTree } from './groups.js'; + +export interface ModelField { + key: string; + label: string; + data_type: string; + nullable?: boolean; + primary_key?: boolean; + /** Collection key and field key in the same store. */ + references?: { collection: string; field: string }; + line: number; +} + +export interface ModelCollection { + /** The entity or table name as code refers to it (`Product`, `orders`). */ + key: string; + /** The name to show: the table name when it is mapped to one. */ + label: string; + path: string; + start: number; + end: number; + fields: ModelField[]; +} + +export interface ModelStore { + key: string; + label: string; + source: 'prisma' | 'sql' | 'efcore'; + storage: 'relational' | 'document'; + /** The file that declares the store: the schema file, or the context class. */ + path: string; + line: number; + collections: ModelCollection[]; +} + +/** Limits that keep one malformed or generated file from flooding the payload. */ +const MAX_COLLECTIONS = 400; +const MAX_FIELDS = 120; + +export interface SourceFile { + path: string; + text: string; +} + +function lineAt(text: string, index: number): number { + let n = 1; + for (let i = 0; i < index && i < text.length; i++) if (text.charCodeAt(i) === 10) n++; + return n; +} + +/** Index of the `}` matching the `{` at `open`, or -1. Ignores braces in strings and comments, roughly. */ +function matchBrace(text: string, open: number): number { + let depth = 0; + let inStr: string | null = null; + for (let i = open; i < text.length; i++) { + const c = text[i]; + if (inStr) { + if (c === '\\') i++; + else if (c === inStr) inStr = null; + continue; + } + if (c === '"' || c === "'" || c === '`') inStr = c; + else if (c === '/' && text[i + 1] === '/') { + const nl = text.indexOf('\n', i); + i = nl < 0 ? text.length : nl; + } else if (c === '{') depth++; + else if (c === '}' && --depth === 0) return i; + } + return -1; +} + +// ─── Prisma ───────────────────────────────────────────────────────────────── + +const PRISMA_SCALARS = new Set(['String', 'Int', 'BigInt', 'Float', 'Decimal', 'Boolean', 'DateTime', 'Json', 'Bytes', 'Unsupported']); + +export function parsePrisma(file: SourceFile): ModelStore | null { + const { text, path } = file; + const provider = /datasource\s+\w+\s*\{[^}]*?provider\s*=\s*"([\w-]+)"/.exec(text)?.[1] ?? null; + const models: { name: string; start: number; end: number; body: string; bodyLine: number }[] = []; + const re = /^[ \t]*model\s+(\w+)\s*\{/gm; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + const open = text.indexOf('{', m.index); + const close = matchBrace(text, open); + if (close < 0) continue; + models.push({ name: m[1], start: lineAt(text, m.index), end: lineAt(text, close), body: text.slice(open + 1, close), bodyLine: lineAt(text, open) }); + } + if (models.length === 0) return null; + const modelNames = new Set(models.map((x) => x.name)); + const collections: ModelCollection[] = []; + for (const model of models.slice(0, MAX_COLLECTIONS)) { + const fields: ModelField[] = []; + const fks = new Map(); + let table = model.name; + model.body.split('\n').forEach((raw, i) => { + const line = model.bodyLine + i; + const l = raw.replace(/\/\/.*$/, '').trim(); + const map = /^@@map\(\s*"([^"]+)"/.exec(l); + if (map) table = map[1]; + const ids = /^@@id\(\s*\[([^\]]+)\]/.exec(l); + if (ids) for (const f of ids[1].split(',').map((s) => s.trim())) fks.set(`@id:${f}`, { collection: '', field: '' }); + const fm = /^(\w+)\s+(\w+)(\[\])?(\?)?(.*)$/.exec(l); + if (!fm || l.startsWith('@@')) return; + const [, name, type, list, optional, rest] = fm; + if (modelNames.has(type)) { + // A relation field: its scalar side carries the foreign key. + const rel = /@relation\([^)]*fields:\s*\[([^\]]+)\][^)]*references:\s*\[([^\]]+)\]/.exec(rest); + if (rel) { + const from = rel[1].split(',').map((s) => s.trim()); + const to = rel[2].split(',').map((s) => s.trim()); + from.forEach((f, k) => fks.set(f, { collection: type, field: to[k] ?? to[0] })); + } + return; + } + if (!PRISMA_SCALARS.has(type) && !/^[A-Z]/.test(type)) return; + fields.push({ + key: name, + label: (/@map\(\s*"([^"]+)"/.exec(rest)?.[1]) ?? name, + data_type: `${type}${list ? '[]' : ''}`, + ...(optional ? { nullable: true } : {}), + ...(/@id\b/.test(rest) ? { primary_key: true } : {}), + line, + }); + }); + for (const f of fields) { + const fk = fks.get(f.key); + if (fk && fk.collection) f.references = fk; + if (fks.has(`@id:${f.key}`)) f.primary_key = true; + } + collections.push({ key: model.name, label: table, path, start: model.start, end: model.end, fields: fields.slice(0, MAX_FIELDS) }); + } + return { + key: `prisma:${path}`, + label: provider ? `Prisma (${provider})` : 'Prisma', + source: 'prisma', + storage: provider === 'mongodb' ? 'document' : 'relational', + path, + line: 1, + collections, + }; +} + +// ─── SQL DDL ──────────────────────────────────────────────────────────────── + +function sqlIdent(raw: string): string { + const last = raw.trim().split('.').pop() ?? raw; + return last.replace(/^[`"[]|[`"\]]$/g, ''); +} + +/** Split a CREATE TABLE body on top-level commas, keeping each part's offset. */ +function splitDefs(body: string): { text: string; offset: number }[] { + const out: { text: string; offset: number }[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i <= body.length; i++) { + const c = body[i]; + if (c === '(') depth++; + else if (c === ')') depth--; + if ((c === ',' && depth === 0) || i === body.length) { + out.push({ text: body.slice(start, i), offset: start }); + start = i + 1; + } + } + return out; +} + +export function parseSqlTables(file: SourceFile): ModelCollection[] { + const { text, path } = file; + const out: ModelCollection[] = []; + const re = /create\s+table\s+(?:if\s+not\s+exists\s+)?([`"[\]\w.]+)\s*\(/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null && out.length < MAX_COLLECTIONS) { + const open = m.index + m[0].length - 1; + let depth = 0; + let close = -1; + for (let i = open; i < text.length; i++) { + if (text[i] === '(') depth++; + else if (text[i] === ')' && --depth === 0) { + close = i; + break; + } + } + if (close < 0) continue; + const name = sqlIdent(m[1]); + const body = text.slice(open + 1, close); + const fields: ModelField[] = []; + const tablePk: string[] = []; + const tableFk: { cols: string[]; table: string; refCols: string[] }[] = []; + for (const def of splitDefs(body)) { + const d = def.text.trim().replace(/\s+/g, ' '); + if (!d) continue; + const line = lineAt(text, open + 1 + def.offset + (def.text.length - def.text.trimStart().length)); + const pk = /^(?:constraint \S+ )?primary key\s*\(([^)]+)\)/i.exec(d); + if (pk) { + tablePk.push(...pk[1].split(',').map(sqlIdent)); + continue; + } + const fk = /^(?:constraint \S+ )?foreign key\s*\(([^)]+)\)\s*references\s+([`"[\]\w.]+)\s*\(([^)]+)\)/i.exec(d); + if (fk) { + tableFk.push({ cols: fk[1].split(',').map(sqlIdent), table: sqlIdent(fk[2]), refCols: fk[3].split(',').map(sqlIdent) }); + continue; + } + if (/^(constraint|unique|check|index|key)\b/i.test(d)) continue; + const col = /^([`"[\]\w]+)\s+([\w]+(?:\s*\([^)]*\))?)(.*)$/.exec(d); + if (!col) continue; + const rest = col[3]; + const ref = /references\s+([`"[\]\w.]+)\s*(?:\(([^)]+)\))?/i.exec(rest); + fields.push({ + key: sqlIdent(col[1]), + label: sqlIdent(col[1]), + data_type: col[2].replace(/\s+/g, ''), + ...(!/not null|primary key/i.test(rest) ? { nullable: true } : {}), + ...(/primary key/i.test(rest) ? { primary_key: true } : {}), + ...(ref ? { references: { collection: sqlIdent(ref[1]), field: ref[2] ? sqlIdent(ref[2]) : 'id' } } : {}), + line, + }); + } + for (const f of fields) { + if (tablePk.includes(f.key)) { + f.primary_key = true; + delete f.nullable; + } + for (const fk of tableFk) { + const k = fk.cols.indexOf(f.key); + if (k >= 0) f.references = { collection: fk.table, field: fk.refCols[k] ?? fk.refCols[0] }; + } + } + out.push({ key: name, label: name, path, start: lineAt(text, m.index), end: lineAt(text, close), fields: fields.slice(0, MAX_FIELDS) }); + } + return out; +} + +// ─── EF Core ──────────────────────────────────────────────────────────────── + +interface CsClass { + name: string; + namespace: string; + /** `using` directives of the file the class is declared in. */ + usings: string[]; + bases: string[]; + path: string; + start: number; + end: number; + body: string; + bodyLine: number; +} + +/** Every class declaration in a C# file, with its body. */ +export function csClasses(file: SourceFile): CsClass[] { + const out: CsClass[] = []; + const namespace = /^\s*namespace\s+([\w.]+)/m.exec(file.text)?.[1] ?? ''; + const usings = [...file.text.matchAll(/^\s*using\s+(?:static\s+)?([\w.]+)\s*;/gm)].map((u) => u[1]); + const re = /\b(?:class|record)\s+(\w+)(?:<[^>{]*>)?(?:\s*\([^)]*\))?\s*(?::\s*([^{\n]+?))?\s*(?:where\b[^{]*)?\{/g; + let m: RegExpExecArray | null; + while ((m = re.exec(file.text)) !== null) { + const open = m.index + m[0].length - 1; + const close = matchBrace(file.text, open); + if (close < 0) continue; + out.push({ + name: m[1], + namespace, + usings, + bases: (m[2] ?? '').split(',').map((s) => s.trim().replace(/<.*$/, '')).filter(Boolean), + path: file.path, + start: lineAt(file.text, m.index), + end: lineAt(file.text, close), + body: file.text.slice(open + 1, close), + bodyLine: lineAt(file.text, open), + }); + } + return out; +} + +const CS_PROP = /^\s*(\[[^\]]*\]\s*)*public\s+(?:(?:virtual|required|override|new)\s+)*([\w.<>,?\[\] ]+?)\s+(\w+)\s*(?:\{\s*get\s*;|=>)/; + +/** Auto-properties declared directly in a class body (not in nested classes), with their lines. */ +function csProps(cls: CsClass): { type: string; name: string; key: boolean; line: number }[] { + const props: { type: string; name: string; key: boolean; line: number }[] = []; + const lines = cls.body.split('\n'); + let depth = 0; + let pendingKey = false; + lines.forEach((raw, i) => { + if (depth === 0) { + if (/^\s*\[\s*Key\s*[\],(]/.test(raw)) pendingKey = true; + const p = CS_PROP.exec(raw); + if (p) { + props.push({ type: p[2].trim(), name: p[3], key: pendingKey || /\[\s*Key\s*[\],(]/.test(raw), line: cls.bodyLine + i }); + pendingKey = false; + } else if (raw.trim() && !raw.trim().startsWith('[') && !raw.trim().startsWith('//')) { + pendingKey = false; + } + } + for (const ch of raw.replace(/"(?:\\.|[^"\\])*"/g, '')) { + if (ch === '{') depth++; + else if (ch === '}') depth--; + } + // An auto-property's own `{ get; set; }` opens and closes on its line. + if (depth < 0) depth = 0; + }); + return props; +} + +const ENTITY_COLLECTION = /^(?:ICollection|IList|List|IEnumerable|HashSet|ISet|IReadOnlyCollection)<\s*(\w+)\s*>$/; + +export function parseEfCore(files: SourceFile[]): ModelStore[] { + const classes = files.flatMap(csClasses); + const candidates = new Map(); + for (const c of classes) candidates.set(c.name, [...(candidates.get(c.name) ?? []), c]); + // Several classes can share a name (an entity and a DTO); prefer the one the + // referring file can see — its own namespace or one it imports (matching on + // a suffix, since solutions rename their root namespace) — then one in a + // domain or entities folder. + const nsMatch = (a: string, b: string) => !!a && !!b && (a === b || a.endsWith(`.${b}`) || b.endsWith(`.${a}`)); + const resolve = (name: string, from: CsClass): CsClass | undefined => { + const list = candidates.get(name); + if (!list || list.length === 0) return undefined; + const score = (c: CsClass) => + (nsMatch(c.namespace, from.namespace) || from.usings.some((u) => nsMatch(c.namespace, u)) ? 4 : 0) + + (/(^|\/)(Entities|Domain|Models?|Aggregates?)\//i.test(c.path) ? 2 : 0); + return [...list].sort((a, b) => score(b) - score(a) || a.path.localeCompare(b.path) || a.start - b.start)[0]; + }; + const stores: ModelStore[] = []; + for (const ctx of classes) { + const sets = csProps(ctx) + .map((p) => ({ p, entity: /^DbSet<\s*(\w+)\s*>$/.exec(p.type)?.[1] })) + .filter((x): x is { p: (typeof x)['p']; entity: string } => !!x.entity); + if (sets.length === 0) continue; + const entities = new Set(sets.map((s) => s.entity)); + const collections: ModelCollection[] = []; + for (const { p, entity } of sets.slice(0, MAX_COLLECTIONS)) { + const cls = resolve(entity, ctx); + if (!cls) { + collections.push({ key: entity, label: p.name, path: ctx.path, start: p.line, end: p.line, fields: [] }); + continue; + } + // The entity's own properties, then its base classes' (an `Id` often lives on a base entity). + const chain: CsClass[] = [cls]; + for (let k = 0, cur = cls; k < 4; k++) { + const base = cur.bases.map((b) => resolve(b, cur)).find(Boolean); + if (!base || chain.includes(base)) break; + chain.push(base); + cur = base; + } + const props = chain.flatMap((c) => csProps(c).map((x) => ({ ...x, owner: c }))); + const navs = new Map(); + for (const x of props) if (entities.has(x.type.replace(/\?$/, ''))) navs.set(x.name, x.type.replace(/\?$/, '')); + const fields: ModelField[] = []; + const seen = new Set(); + for (const x of props) { + const bare = x.type.replace(/\?$/, ''); + if (seen.has(x.name) || entities.has(bare) || ENTITY_COLLECTION.test(bare)) continue; + seen.add(x.name); + const isKey = x.key || x.name === 'Id' || x.name === `${entity}Id`; + // `XId` pairs with a navigation named `X`, or else with one whose type is the entity `X`. + const stem = x.name.endsWith('Id') && x.name.length > 2 ? x.name.slice(0, -2) : null; + const nav = stem ? (navs.get(stem) ?? (entities.has(stem) && [...navs.values()].includes(stem) ? stem : undefined)) : undefined; + const field: ModelField = { + key: x.name, + label: x.name, + data_type: x.type, + ...(x.type.endsWith('?') ? { nullable: true } : {}), + ...(isKey ? { primary_key: true } : {}), + ...(nav ? { references: { collection: nav, field: 'Id' } } : {}), + line: x.line, + }; + // A key inherited from a base class is pinned where it is declared. + if (x.owner !== cls) field.label = x.name; + fields.push(field); + } + collections.push({ key: entity, label: p.name, path: cls.path, start: cls.start, end: cls.end, fields: fields.slice(0, MAX_FIELDS) }); + } + stores.push({ key: `efcore:${ctx.name}`, label: ctx.name, source: 'efcore', storage: 'relational', path: ctx.path, line: ctx.start, collections }); + } + return stores; +} + +// ─── All sources ──────────────────────────────────────────────────────────── + +/** Which files could declare a data model. The caller reads only these. */ +export function isModelFile(path: string): boolean { + return /\.(prisma|sql)$/i.test(path) || /\.cs$/i.test(path); +} + +/** + * Data models declared across `files`. `.cs` files are passed only when they + * mention `DbSet<` or declare classes an entity might be; the caller decides. + * Deterministic: stores and collections are sorted. + */ +export function extractDataModels(files: SourceFile[]): ModelStore[] { + const stores: ModelStore[] = []; + const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path)); + for (const f of sorted) if (/\.prisma$/i.test(f.path)) { + const s = parsePrisma(f); + if (s) stores.push(s); + } + const sqlTables = sorted.filter((f) => /\.sql$/i.test(f.path)).flatMap(parseSqlTables); + if (sqlTables.length > 0) { + // Migrations redeclare tables; the latest file (by path order) wins. + const byName = new Map(); + for (const t of sqlTables) byName.set(t.key.toLowerCase(), t); + const collections = [...byName.values()].sort((a, b) => a.key.localeCompare(b.key)); + stores.push({ key: 'sql', label: 'SQL schema', source: 'sql', storage: 'relational', path: collections[0].path, line: collections[0].start, collections }); + } + stores.push(...parseEfCore(sorted.filter((f) => /\.cs$/i.test(f.path)))); + return stores.sort((a, b) => a.key.localeCompare(b.key)); +} + +// ─── Reading the reviewed side ────────────────────────────────────────────── + +/** Files read at most, and the largest one read, so a vendored tree cannot stall a review. */ +const MAX_FILES = 4000; +const MAX_BYTES = 512 * 1024; + +/** + * The data models on the head side of a change: the working tree when the + * review is in place, the head commit otherwise. Paths are repo-relative. + * Never throws; a repository with no model files yields none. + */ +export function readDataModels( + change: ChangeSet, + sides: { base?: string; inPlace?: boolean }, + run: GitRunner = defaultRun, +): ModelStore[] { + const fromTree = headIsWorkingTree(sides); + const listed = fromTree + ? run(['ls-files', '-z', '--cached', '--others', '--exclude-standard'], change.topLevel) + : run(['ls-tree', '-r', '-z', '--name-only', change.headSha], change.topLevel); + if (listed.status !== 0) return []; + let paths = [...new Set(listed.stdout.split('\0').filter((p) => p && isModelFile(p)))].sort(); + if (!fromTree && paths.some((p) => /\.cs$/i.test(p))) { + // Reading a commit costs a git call per file: skip C# unless one file declares an EF Core context. + const hits = run(['grep', '-l', '-z', '-F', 'DbSet<', change.headSha, '--', '*.cs'], change.topLevel); + if (hits.status !== 0 || !hits.stdout.trim()) paths = paths.filter((p) => !/\.cs$/i.test(p)); + } + paths = paths.slice(0, MAX_FILES); + const files: SourceFile[] = []; + for (const p of paths) { + let text: string | null = null; + if (fromTree) { + try { + const abs = path.join(change.topLevel, p); + const st = fs.statSync(abs); + if (st.isFile() && st.size <= MAX_BYTES) text = fs.readFileSync(abs, 'utf8'); + } catch { + text = null; + } + } else { + const res = run(['show', `${change.headSha}:${p}`], change.topLevel); + if (res.status === 0 && res.stdout.length <= MAX_BYTES) text = res.stdout; + } + if (text !== null) files.push({ path: p, text }); + } + return extractDataModels(files); +} diff --git a/src/review/derive.test.ts b/src/review/derive.test.ts new file mode 100644 index 0000000..ff32673 --- /dev/null +++ b/src/review/derive.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest'; +import type { GraphEdge, GraphNode, VgGraph } from '../schema.js'; +import type { HaileProvider } from '../engine/haile/haile-provider.js'; +import type { ChangeSet, ChangedFile } from './git.js'; +import { groupChangeSet } from './groups.js'; +import { buildReviewDoc, renderReviewDocMarkdown, validateReviewDoc, type PinResolver } from './doc.js'; +import { deriveDiagrams, INSTALL_HINT, mapPrefix, rolesOf, trimGraph, type DeriveInput } from './derive.js'; +import type { HaileSidecar } from '../engine/haile/types.js'; + +/** + * The host side of graph-derived diagrams. The derivation itself lives in the + * Architecture module and is tested there; these tests pin what the host + * owns: what it sends, what it refuses to show, and what it says when the + * module is missing. + */ + +const TOP = '/repo'; +const MAP_ROOT = '/repo/pkg'; + +function node(id: string, over: Partial = {}): GraphNode { + return { + id, + kind: 'function', + name: id, + qualifiedName: id, + file: 'src/app.ts', + span: { start: 1, end: 10 }, + lang: 'ts', + importance: 0.1, + centrality: { degree: 0, pagerank: 0, betweenness: 0, eigenvector: 0 }, + area: 0, + isHub: false, + tested: false, + ...over, + }; +} + +function edge(kind: GraphEdge['kind'], src: string, dst: string, over: Partial = {}): GraphEdge { + return { id: `${kind}:${src}>${dst}`, kind, src, dst, resolution: 'tsc', confidence: 1, ...over }; +} + +const graph = (nodes: GraphNode[], edges: GraphEdge[]) => ({ schemaVersion: 'vg-graph/1.1', nodes, edges, areas: [] }) as unknown as VgGraph; + +const files: ChangedFile[] = [{ path: 'pkg/src/store.ts', op: 'modified', addedLines: 3, removedLines: 1, hunks: [{ start: 42, end: 44 }] }]; +const change: ChangeSet = { topLevel: TOP, baseSha: 'a'.repeat(40), headSha: 'b'.repeat(40), mergeBase: null, ref: null, dirty: false, dirtyTreeHash: null, files, remote: null }; +const resolve: PinResolver = (_side, p) => (p.startsWith('pkg/src/') ? 200 : null); + +const save = node('save', { file: 'src/store.ts', span: { start: 40, end: 60 }, duties: [{ k: 'persist', live: true, line: 43 }] }); +const head = graph( + [node('main'), save, node('Order', { kind: 'interface' }), node('helper', { file: 'src/other.ts', duties: [{ k: 'log', live: true, line: 3 }] })], + [ + edge('call', 'main', 'save', { sites: [12], awaited: true }), + edge('import', 'main', 'save'), + edge('references', 'save', 'Order'), + ], +); +const input = (over: Partial = {}): DeriveInput => ({ change, head, mapRoot: MAP_ROOT, resolve, ...over }); + +const stackBlock = { + type: 'call_stack_diff', + title: 'How save is reached', + primary: true, + base_status: 'not_computed', + base: [], + head: [ + { key: 'pkg/src/app.ts#main', label: 'main', pin: { side: 'head', path: 'pkg/src/app.ts', start: 1, end: 10 }, origin: 'graph' }, + { key: 'pkg/src/store.ts#save', parent_key: 'pkg/src/app.ts#main', label: 'save', pin: { side: 'head', path: 'pkg/src/store.ts', start: 40, end: 60 }, via: { kind: 'async' }, status: 'modified', origin: 'graph' }, + ], +}; +const contractBlock = { + type: 'callout', + tone: 'warning', + text: '**Signature changed:** `save`', + pins: [{ side: 'base', path: 'pkg/src/store.ts', start: 40, end: 58 }, { side: 'head', path: 'pkg/src/store.ts', start: 40, end: 60 }], +}; + +function fakeProvider(result: ReturnType>, seen?: { payload?: unknown }): HaileProvider { + return { + version: () => 'test', + classify: () => null, + reviewDiagrams: (payload) => { + if (seen) seen.payload = payload; + return result; + }, + }; +} + +describe('mapPrefix', () => { + it('is the map root relative to the repository, empty at the root', () => { + expect(mapPrefix(change, MAP_ROOT)).toBe('pkg'); + expect(mapPrefix(change, TOP)).toBe(''); + }); +}); + +describe('trimGraph', () => { + const trimmed = trimGraph(head, new Set(['src/store.ts'])); + + it('keeps callables and call / reference edges between them, nothing else', () => { + expect((trimmed.nodes as { id: string }[]).map((n) => n.id)).toEqual(['main', 'save', 'helper']); + expect(trimmed.edges).toEqual([{ kind: 'call', src: 'main', dst: 'save', res: 'tsc', conf: 1, sites: [12], awaited: true }]); + }); + + it('sends duties only for nodes in changed files', () => { + const byId = new Map((trimmed.nodes as { id: string; duties?: unknown }[]).map((n) => [n.id, n])); + expect(byId.get('save')!.duties).toBeDefined(); + expect(byId.get('helper')!.duties).toBeUndefined(); + }); + + it('also sends the duties of functions a changed one calls, where its reads and writes often run', () => { + const repo = node('repo', { file: 'src/repo.ts', duties: [{ k: 'persist', o: 'Order', live: true, line: 7 }] }); + const g = graph([save, repo, node('far', { file: 'src/far.ts', duties: [{ k: 'log', live: true, line: 1 }] })], [edge('call', 'save', 'repo'), edge('call', 'repo', 'far')]); + const byId = new Map((trimGraph(g, new Set(['src/store.ts'])).nodes as { id: string; duties?: unknown }[]).map((n) => [n.id, n])); + expect(byId.get('repo')!.duties).toBeDefined(); + expect(byId.get('far')!.duties).toBeDefined(); + }); + + it('names where every function dispatches work without a call edge (its inherited duties)', () => { + const controller = node('ctl', { file: 'src/ctl.ts', duties: [{ k: 'persist', via: 'Handler.Handle', live: true, line: 0, hop: 1 }, { k: 'query', via: 'Handler.Handle', live: true, line: 0, hop: 1 }] }); + const out = trimGraph(graph([controller], []), new Set()).nodes as { id: string; disp?: string[]; duties?: unknown }[]; + expect(out[0].disp).toEqual(['Handler.Handle']); + expect(out[0].duties).toBeUndefined(); + }); +}); + +describe('rolesOf', () => { + const sym = (node_id: string, primary: string, band = 'high', purposes: { purpose: string; confidence: number }[] = []) => + ({ node_id, role: { primary, alternatives: [], confidence: 0.9, band }, purposes }) as unknown as HaileSidecar['symbols'][0]; + + it('takes each node’s role from the sidecar, and a method takes its type’s', () => { + const cls = node('Handler', { kind: 'class', file: 'src/h.cs', span: { start: 1, end: 40 } }); + const m = node('Handler.Handle', { kind: 'method', file: 'src/h.cs', span: { start: 5, end: 20 } }); + const other = node('Other.Run', { kind: 'method', file: 'src/o.cs', span: { start: 5, end: 20 } }); + const sidecar = { symbols: [sym('Handler', 'use_case', 'high', [{ purpose: 'persist', confidence: 0.9 }, { purpose: 'log', confidence: 0.2 }])] } as unknown as HaileSidecar; + const roles = rolesOf(graph([cls, m, other], []), sidecar); + expect(roles.get('Handler.Handle')).toEqual({ role: 'use_case', purposes: ['persist'] }); + expect(roles.has('Other.Run')).toBe(false); + }); + + it('an abstained classification is not a role', () => { + const sidecar = { symbols: [sym('save', 'repository', 'abstain')] } as unknown as HaileSidecar; + expect(rolesOf(graph([save], []), sidecar).get('save')?.role).toBe('unknown'); + const trimmed = trimGraph(graph([save], []), new Set(['src/store.ts']), rolesOf(graph([save], []), sidecar)).nodes as { role?: string }[]; + expect(trimmed[0].role).toBeUndefined(); + }); +}); + +describe('deriveDiagrams', () => { + it('without the module: no diagrams, and the install command', () => { + const d = deriveDiagrams(input(), null); + expect(d.blocks).toEqual([]); + expect(d.notes).toEqual([INSTALL_HINT]); + expect(INSTALL_HINT).toContain('vg module install arch'); + }); + + it('with a module that predates review diagrams: says so', () => { + const d = deriveDiagrams(input(), { version: () => 'old', classify: () => null }); + expect(d.notes[0]).toMatch(/predates review diagrams/); + }); + + it('sends the trimmed maps, the prefix and the hunks', () => { + const seen: { payload?: unknown } = {}; + deriveDiagrams(input(), fakeProvider({ blocks: [], contract: [], notes: [] }, seen)); + expect(seen.payload).toMatchObject({ + prefix: 'pkg', + base: null, + files: [{ path: 'pkg/src/store.ts', op: 'modified', hunks: [[42, 44]] }], + }); + }); + + it('carries the structural fold, keeping only files whose pins land', () => { + const fold = [ + { path: 'pkg/src/store.ts', text: '- `save(order)` [L40–60](head:pkg/src/store.ts#L40-L60) _edited_' }, + { path: 'other/gone.ts', text: '- `old()` [L1–3](head:other/gone.ts#L1-L3) _edited_' }, + ]; + const d = deriveDiagrams(input(), fakeProvider({ blocks: [stackBlock], contract: [], notes: [], fold })); + expect([...(d.fold ?? new Map()).keys()]).toEqual(['pkg/src/store.ts']); + expect(d.notes.join(' ')).toMatch(/one file was left unfolded/); + const groups = groupChangeSet(change, undefined, null); + const doc = buildReviewDoc({ change, groups, repoKey: null, resolve, design: d }); + expect(validateReviewDoc(doc, resolve)).toEqual([]); + expect(renderReviewDocMarkdown(doc)).toMatch(/- `pkg\/src\/store.ts` modified[^\n]*\n {2}- `save\(order\)` `L40–60` _edited_/); + }); + + it('keeps blocks whose pins land, and makes the first one primary', () => { + const d = deriveDiagrams(input(), fakeProvider({ blocks: [stackBlock], contract: [contractBlock], notes: ['from the module'] })); + expect(d.blocks).toHaveLength(1); + expect(d.blocks[0]).toMatchObject({ type: 'call_stack_diff', primary: true }); + expect(d.implementation).toHaveLength(1); + expect(d.notes).toEqual(['from the module']); + }); + + it('drops a block whose pins do not land, and says so', () => { + const short: PinResolver = (_s, p) => (p === 'pkg/src/store.ts' ? 30 : 200); + const d = deriveDiagrams(input({ resolve: short }), fakeProvider({ blocks: [stackBlock], contract: [contractBlock], notes: [] })); + expect(d.blocks).toEqual([]); + expect(d.implementation).toEqual([]); + expect(d.notes.join(' ')).toMatch(/"How save is reached" was left out because its pins do not land/); + expect(d.notes.join(' ')).toMatch(/signature change was left out/); + }); + + it('drops a malformed block from the module rather than trusting it', () => { + const bad = { ...stackBlock, head: [{ ...stackBlock.head[0], via: { kind: 'teleport' } }] }; + expect(deriveDiagrams(input(), fakeProvider({ blocks: [bad], contract: [], notes: [] })).blocks).toEqual([]); + }); + + it('survives a module that abstains or throws', () => { + expect(deriveDiagrams(input(), fakeProvider(null)).notes[0]).toMatch(/could not derive/); + const throwing: HaileProvider = { version: () => 'x', classify: () => null, reviewDiagrams: () => { throw new Error('boom'); } }; + expect(deriveDiagrams(input(), throwing).blocks).toEqual([]); + }); + + it('feeds a review document that validates and renders, with signature changes under implementation', () => { + const design = deriveDiagrams(input(), fakeProvider({ blocks: [stackBlock], contract: [contractBlock], notes: [] })); + const doc = buildReviewDoc({ change, groups: groupChangeSet(change), repoKey: null, resolve, design }); + expect(doc.sections.map((s) => s.kind)).toEqual(['what_why', 'design', 'implementation']); + expect(doc.sections[2].blocks.some((b) => b.type === 'callout')).toBe(true); + expect(validateReviewDoc(doc, resolve)).toEqual([]); + const md = renderReviewDocMarkdown(doc); + expect(md).toContain('**How save is reached**'); + expect(md).toContain('_not computed_'); + expect(md).toContain('Signature changed'); + }); +}); diff --git a/src/review/derive.ts b/src/review/derive.ts new file mode 100644 index 0000000..2a66c7e --- /dev/null +++ b/src/review/derive.ts @@ -0,0 +1,248 @@ +/** + * Graph-derived diagrams for the review document — the host side. + * + * Which changed function a review leads with, how its call path is chosen and + * folded, how each hop is labelled, how a function's duties become a flow, and + * when a signature change is reported are decided by the Architecture module + * (`HaileProvider.reviewDiagrams`), not here. This file only: + * + * 1. trims the code maps to the fields the module reads, + * 2. hands them over with the change's files and hunks, + * 3. validates every returned block against the reviewed base and head, + * dropping any whose pins do not land, and says so. + * + * Without the module there are no diagrams, and the document says how to get + * them. The validation stays here, in the open, so anyone can check a + * document without the module. + */ + +import * as path from 'node:path'; +import type { GraphNode, VgGraph } from '../schema.js'; +import type { HaileProvider } from '../engine/haile/haile-provider.js'; +import type { ChangeSet } from './git.js'; +import { DOC_SCHEMA, validateReviewDoc, type DocBlock, type PinResolver } from './doc.js'; +import type { ModelStore } from './data-models.js'; +import type { ArchOverview } from '../engine/chart/arch-types.js'; +import type { HaileSidecar } from '../engine/haile/types.js'; + +export interface DerivedDiagrams { + /** Design blocks, the primary first. */ + blocks: DocBlock[]; + /** Implementation callouts (signature changes). */ + implementation: DocBlock[]; + /** What was derived, what was not, and why. */ + notes: string[]; + /** The structural fold: per changed file (repo path), Markdown sub-bullets of its changed functions. */ + fold?: Map; +} + +export interface DeriveInput { + change: ChangeSet; + head: VgGraph; + /** Graph built at the change's base commit, when the caller asked for one. */ + base?: VgGraph | null; + /** Directory the code map was built from, so graph paths map to repo paths. */ + mapRoot: string; + /** Data models the repository declares on the head side (data-models.ts), repo-relative. */ + models?: ModelStore[]; + /** The architecture sidecar (`graph.arch.json`), for each node's role and purposes. */ + sidecar?: HaileSidecar | null; + /** The architecture overview (`vg show arch`): packages and package edges. */ + overview?: ArchOverview | null; + /** What the system is called on the map (the repository's name). */ + system?: string; + /** `explain`: the change's one span is code to explain, not an edit — no statuses, no before side. */ + mode?: 'change' | 'explain'; + resolve: PinResolver; +} + +export const INSTALL_HINT = 'diagrams need the Architecture module — run `vg module install arch`, then run this again'; + +const CALLER_KINDS = new Set(['function', 'method', 'class', 'component', 'route', 'file']); + +/** The map root relative to the repository, as the module joins paths with it. */ +export function mapPrefix(change: ChangeSet, mapRoot: string): string { + const rel = path.relative(change.topLevel, mapRoot).split(path.sep).join('/'); + return rel === '.' ? '' : rel; +} + +type Roles = Map; + +/** + * Each node's architecture role and top purposes, from the sidecar. The + * sidecar classifies types; a method takes the role of the type whose span + * encloses it. Facts the module already decided, passed back to it. + */ +export function rolesOf(g: VgGraph, sidecar: HaileSidecar | null | undefined): Roles { + const roles: Roles = new Map(); + if (!sidecar) return roles; + for (const s of sidecar.symbols) { + if (!s.role?.primary) continue; + roles.set(s.node_id, { + role: s.role.band === 'abstain' ? 'unknown' : s.role.primary, + purposes: (s.purposes ?? []).filter((p) => p.confidence >= 0.5).slice(0, 3).map((p) => p.purpose), + }); + } + const typesByFile = new Map(); + for (const n of g.nodes) { + if (roles.has(n.id) && n.kind !== 'function' && n.kind !== 'method') typesByFile.set(n.file, [...(typesByFile.get(n.file) ?? []), n]); + } + for (const n of g.nodes) { + if ((n.kind !== 'function' && n.kind !== 'method') || roles.has(n.id)) continue; + const owner = (typesByFile.get(n.file) ?? []) + .filter((t) => t.span.start <= n.span.start && t.span.end >= n.span.end) + .sort((a, b) => a.span.end - a.span.start - (b.span.end - b.span.start))[0]; + if (owner) roles.set(n.id, roles.get(owner.id)!); + } + return roles; +} + +function trimNode(n: GraphNode, withDetail: boolean, roles?: Roles): Record { + const out: Record = { + id: n.id, + kind: n.kind, + name: n.name, + qn: n.qualifiedName, + file: n.file, + start: n.span.start, + end: n.span.end, + importance: n.importance, + }; + if (n.signature) out.sig = n.signature; + if (withDetail && n.duties) out.duties = n.duties; + const r = roles?.get(n.id); + if (r && r.role !== 'unknown') out.role = r.role; + if (r && withDetail && r.purposes.length > 0) out.purp = r.purposes; + // Where this function hands work to another without a call edge the map + // resolved (a mediator, a bus): the sources of its inherited duties. Names + // only, for every callable, so an entry point is found from any change. + const dispatch = [...new Set((n.duties ?? []).filter((d) => (d.hop ?? 0) > 0 && d.via).map((d) => d.via as string))].sort(); + if (dispatch.length > 0) out.disp = dispatch.slice(0, 8); + if (withDetail && n.effects) { + const e = n.effects as unknown as Record; + out.effects = { branches: e.branches ?? 0, loops: e.loops ?? 0, throws: e.throws ?? 0, awaits: e.awaits ?? 0 }; + } + return out; +} + +/** + * The module reads callables and their callers, call and callback-reference + * edges, and duties only for nodes in changed files and the functions they + * call within two hops (a changed handler's reads and writes often run in a + * repository it calls). Everything else stays behind, which keeps the payload + * a fraction of the map. + */ +export function trimGraph(g: VgGraph, changedFiles: Set, roles?: Roles): { nodes: unknown[]; edges: unknown[] } { + const nodes = g.nodes.filter((n) => CALLER_KINDS.has(n.kind)); + const ids = new Set(nodes.map((n) => n.id)); + const edges = g.edges + .filter((e) => (e.kind === 'call' || e.kind === 'references') && ids.has(e.src) && ids.has(e.dst)) + .map((e) => ({ + kind: e.kind, + src: e.src, + dst: e.dst, + res: e.resolution, + conf: e.confidence, + ...(e.sites ? { sites: e.sites } : {}), + ...(e.awaited ? { awaited: true } : {}), + })); + const detail = new Set(nodes.filter((n) => changedFiles.has(n.file)).map((n) => n.id)); + let frontier = new Set(detail); + for (let hop = 0; hop < 2 && frontier.size > 0; hop++) { + const next = new Set(); + for (const e of edges) { + if (e.kind === 'call' && frontier.has(e.src) && !detail.has(e.dst)) { + detail.add(e.dst); + next.add(e.dst); + } + } + frontier = next; + } + return { nodes: nodes.map((n) => trimNode(n, detail.has(n.id), roles)), edges }; +} + +/** True when a block passes every rule, pins included, on its own. */ +function blockIsValid(block: DocBlock, resolve: PinResolver, design: boolean): boolean { + const probe = { + schema_version: DOC_SCHEMA, + title: 'probe', + target: { repo_key: null, base_sha: '', head_sha: 'probe', merge_base: null, dirty_tree_hash: null }, + sections: [{ kind: design ? 'design' : 'implementation', blocks: [design ? { ...block, primary: true } : block] }], + groups_digest: null, + generator: { by: 'vg', notes: [] }, + }; + return validateReviewDoc(probe, resolve).length === 0; +} + +function title(block: DocBlock): string { + return 'title' in block && typeof block.title === 'string' ? block.title : block.type; +} + +/** + * Ask the Architecture module for the diagrams and keep only what proves out. + * Never throws: a missing or older module, or one that abstains, yields no + * diagrams and a note saying why. + */ +export function deriveDiagrams(input: DeriveInput, provider: HaileProvider | null): DerivedDiagrams { + if (!provider?.reviewDiagrams) { + return { blocks: [], implementation: [], notes: [provider ? `${INSTALL_HINT} (the installed module predates review diagrams)` : INSTALL_HINT] }; + } + const prefix = mapPrefix(input.change, input.mapRoot); + const strip = (p: string) => (prefix && p.startsWith(`${prefix}/`) ? p.slice(prefix.length + 1) : prefix ? null : p); + const changed = new Set( + input.change.files.map((f) => strip(f.path.replace(/\\/g, '/'))).filter((p): p is string => p !== null), + ); + const payload = { + head: trimGraph(input.head, changed, rolesOf(input.head, input.sidecar)), + base: input.base ? trimGraph(input.base, changed) : null, + prefix, + ...(input.mode === 'explain' ? { mode: 'explain' } : {}), + models: input.models ?? [], + ...(input.overview + ? { + overview: { + packages: input.overview.packages.map((p) => ({ id: p.id, name: p.name, path: p.path, lane: p.lane, job: p.job })), + edges: input.overview.edges.map((e) => ({ src: e.src, dst: e.dst, kind: e.kind, weight: e.weight })), + }, + system: input.system ?? '', + } + : {}), + files: input.change.files.map((f) => ({ + path: f.path.replace(/\\/g, '/'), + op: f.op, + hunks: f.hunks.map((h) => [h.start, h.end]), + })), + }; + let raw: ReturnType> = null; + try { + raw = provider.reviewDiagrams(payload); + } catch { + raw = null; + } + if (!raw) return { blocks: [], implementation: [], notes: ['the Architecture module could not derive diagrams for this change'] }; + + const notes = [...raw.notes]; + const blocks: DocBlock[] = []; + for (const b of raw.blocks as DocBlock[]) { + if (blockIsValid(b, input.resolve, true)) blocks.push({ ...b, primary: blocks.length === 0 } as DocBlock); + else notes.push(`"${title(b)}" was left out because its pins do not land — rebuild the code map with \`vg\` and run again`); + } + const implementation: DocBlock[] = []; + for (const b of raw.contract as DocBlock[]) { + if (blockIsValid(b, input.resolve, false)) implementation.push(b); + else notes.push('a signature change was left out because its pins do not land'); + } + // The fold is shown only where every pin it carries lands on the change. + const fold = new Map(); + let unfolded = 0; + for (const f of raw.fold ?? []) { + const probe = validateReviewDoc( + { schema_version: DOC_SCHEMA, title: 'fold', target: { base_sha: '', head_sha: 'x' }, sections: [{ kind: 'implementation', blocks: [{ type: 'markdown', text: f.text }] }], generator: { by: 'vg', notes: [] } }, + input.resolve, + ); + if (probe.length === 0) fold.set(f.path, f.text); + else unfolded += 1; + } + if (unfolded > 0) notes.push(`${unfolded === 1 ? 'one file was' : `${unfolded} files were`} left unfolded because the code map no longer matches it — rebuild it with \`vg\``); + return { blocks, implementation, notes, ...(fold.size > 0 ? { fold } : {}) }; +} diff --git a/src/review/doc-build.ts b/src/review/doc-build.ts new file mode 100644 index 0000000..002c199 --- /dev/null +++ b/src/review/doc-build.ts @@ -0,0 +1,162 @@ +/** + * Build a review document for a scope: the one pipeline behind `vg review + * doc`, `vg review groups --session` and the `review_doc` MCP tool, so the + * three can never disagree about what a change contains. + * + * A scope is either the change (working tree vs HEAD, or HEAD against a base + * ref) or one VG Code session. Resolving it gives the checkout to read, the + * change set, and the pin resolver; building runs grouping, the deterministic + * findings and the graph-derived diagrams when a code map exists, and the + * session's provenance for a session scope. vg never returns a document that + * fails its own validation. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { resolveGraphPath } from '../engine/artifacts.js'; +import { overviewOf } from '../engine/chart/server.js'; +import { readHaileSidecar } from '../engine/haile/sidecar.js'; +import { loadHaileProvider } from '../engine/haile/haile-provider.js'; +import { loadGraph } from '../engine/load.js'; +import { CliError, ExitCode } from '../util/exit.js'; +import { buildBaseGraph, type BaseGraphResult } from './base-graph.js'; +import { readDataModels } from './data-models.js'; +import { deriveDiagrams, type DerivedDiagrams } from './derive.js'; +import { buildReviewDoc, makePinResolver, validateReviewDoc, type PinResolver, type ReviewDoc } from './doc.js'; +import { collectChangeSet, defaultRun, isGitRepo, repoKey, type ChangeSet } from './git.js'; +import { collectGroupSignals, groupChangeSet, type DiffGroups } from './groups.js'; +import { runReview, type RunReviewResult } from './run.js'; +import { resolveReviewSession, scopeChangeToSession, sessionBlocks, type ReviewSession, type ScopedChange } from './session.js'; + +/** What a document covers. `latest` is resolved to a concrete session id before a document is saved. */ +export type DocScope = + | { kind: 'change'; base: string | null; in_place: boolean } + | { kind: 'session'; session: string; base?: string | null }; + +export interface ResolvedScope { + /** The scope with `latest` replaced by the session it named. */ + scope: DocScope; + /** The checkout the change is read from (a worktree for a worktree chat). */ + root: string; + /** The change, already narrowed to the session's files for a session scope. */ + change: ChangeSet; + /** The options git and the pin resolver read the two sides with. */ + sides: { base?: string; inPlace?: boolean }; + session: { review: ReviewSession; scoped: ScopedChange } | null; +} + +function requireChange(root: string, sides: { base?: string; inPlace?: boolean }): ChangeSet { + if (!isGitRepo(root)) { + throw new CliError(`\`vg review\` needs a git repository — ${root} is not one (or git is not on PATH)`, ExitCode.USAGE_ERROR); + } + return collectChangeSet(root, sides.base, defaultRun, { inPlace: sides.inPlace }); +} + +/** Turn a scope into a checkout, a change and its sides. Throws a CliError the caller can show as is. */ +export function resolveScope(mainRoot: string, scope: DocScope): ResolvedScope { + if (scope.kind === 'change') { + const sides = { base: scope.base ?? undefined, inPlace: scope.in_place || undefined }; + return { scope, root: mainRoot, change: requireChange(mainRoot, sides), sides, session: null }; + } + const rs = resolveReviewSession(mainRoot, scope.session); + if ('error' in rs) throw new CliError(rs.error, ExitCode.NOT_FOUND); + const resolved: DocScope = { kind: 'session', session: rs.session.id, ...(scope.base && !rs.base ? { base: scope.base } : {}) }; + if (rs.base) { + if (!fs.existsSync(rs.root)) { + throw new CliError( + `session ${rs.session.id} ran in worktree ${rs.session.worktree?.id ?? ''}, which no longer exists — after Apply, review the main tree with \`vg review doc\``, + ExitCode.NOT_FOUND, + ); + } + // A worktree chat's change is everything since the worktree branched, committed or not. + const sides = { base: rs.base, inPlace: true }; + const scoped = scopeChangeToSession(requireChange(rs.root, sides), rs); + return { scope: resolved, root: rs.root, change: scoped.change, sides, session: { review: rs, scoped } }; + } + // A chat in the main tree: its uncommitted work, or with a base ref, its commits too. + const sides = scope.base ? { base: scope.base, inPlace: true } : {}; + const scoped = scopeChangeToSession(requireChange(mainRoot, sides), rs); + return { scope: resolved, root: mainRoot, change: scoped.change, sides, session: { review: rs, scoped } }; +} + +export function scopeResolver(r: ResolvedScope): PinResolver { + return makePinResolver(r.change, r.sides); +} + +export interface BuildOptions { + findings?: boolean; + diagrams?: boolean; + baseGraph?: boolean; + graphPath?: string; + generatedAt?: string; + /** Progress lines for a person watching (the CLI's stderr); omitted for MCP. */ + log?: (line: string) => void; +} + +export interface BuiltDocument { + doc: ReviewDoc; + groups: DiffGroups; + resolved: ResolvedScope; + resolve: PinResolver; +} + +/** The deterministic document for a scope, validated. */ +export async function buildDocument(mainRoot: string, scope: DocScope, o: BuildOptions = {}): Promise { + const resolved = resolveScope(mainRoot, scope); + const { root, change, sides } = resolved; + const resolve = scopeResolver(resolved); + const provider = await loadHaileProvider(); + const groups = groupChangeSet(change, collectGroupSignals(change, sides), provider); + let reviewed: RunReviewResult | null = null; + // Findings and diagrams need the code map. Use one that exists; never build + // one here — the document is still useful without them, and says so. + const head = change.files.length > 0 && (o.findings !== false || o.diagrams !== false) ? loadGraph(root, o.graphPath) : null; + if (o.findings !== false && head) { + reviewed = await runReview({ + root, + base: sides.base, + inPlace: sides.inPlace, + local: true, + offline: true, + graphPath: o.graphPath, + generatedAt: o.generatedAt, + signingKey: null, + change, + }); + } + let design: DerivedDiagrams | null = null; + if (o.diagrams !== false && head) { + let base: BaseGraphResult | null = null; + if (o.baseGraph) { + o.log?.('building the code map at the base commit…'); + base = await buildBaseGraph(change, root); + o.log?.(base.graph ? `base code map built in ${(base.ms / 1000).toFixed(1)}s` : `base code map not built: ${base.reason}`); + } + const models = readDataModels(change, sides); + // The architecture the module already projected for `vg show arch`: + // packages become the map's containers, roles its components. + const sidecar = readHaileSidecar(resolveGraphPath(root, o.graphPath)); + const overview = sidecar ? overviewOf(head, sidecar, provider) : null; + const system = change.remote ? (change.remote.split('/').pop() ?? '').replace(/\.git$/, '') : path.basename(change.topLevel); + design = deriveDiagrams({ change, head, base: base?.graph ?? null, mapRoot: root, resolve, models, sidecar, overview, system }, provider); + if (base && !base.graph) design.notes.unshift(`the base code map was not built: ${base.reason}`); + } else if (o.diagrams !== false && change.files.length > 0) { + design = { blocks: [], implementation: [], notes: ['no code map was found, so no diagram was derived — run `vg` first'] }; + } + const doc = buildReviewDoc({ + change, + groups, + repoKey: repoKey(change.remote, change.topLevel), + resolve, + findings: reviewed ? reviewed.receipt.findings : null, + capsule: reviewed ? reviewed.capsule : null, + design, + session: resolved.session ? sessionBlocks(resolved.session.review, resolved.session.scoped, resolve) : null, + }); + const issues = validateReviewDoc(doc, resolve); + if (issues.length > 0) { + // vg must never emit a document that fails its own rules. + throw new CliError(`internal: generated review document failed validation — ${issues[0].path}: ${issues[0].message}`, ExitCode.ERROR); + } + return { doc, groups, resolved, resolve }; +} diff --git a/src/review/doc-comments.test.ts b/src/review/doc-comments.test.ts new file mode 100644 index 0000000..fbc7383 --- /dev/null +++ b/src/review/doc-comments.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { fetchComments, formatThreads, threadsOf, type DocComment } from './doc-comments.js'; +import type { ParsedDsn } from './push.js'; + +const c = (over: Partial): DocComment => ({ + id: 'rdc_1', blockId: 'blk_1', blockTitle: 'What save does', parentId: null, authorKind: 'person', + authorName: 'Ada', body: 'Why?', createdAt: '2026-10-02T10:00:00Z', resolvedAt: null, ...over, +}); + +describe('threads', () => { + it('groups replies under their thread and lists open threads first', () => { + const threads = threadsOf([ + c({ id: 'rdc_1', resolvedAt: 't' }), + c({ id: 'rdc_2', blockTitle: null, blockId: 'blk_9', body: 'And this?' }), + c({ id: 'rdc_3', parentId: 'rdc_2', authorKind: 'agent', authorName: 'Agent (review_doc)', body: 'Because…\nsee L3' }), + ]); + expect(threads.map((t) => [t.id, t.comments.length, t.resolved])).toEqual([ + ['rdc_1', 1, true], + ['rdc_2', 2, false], + ]); + expect(formatThreads(threads)).toEqual([ + 'rdc_2 on blk_9', + ' Bo: And this?'.replace('Bo', 'Ada'), + ' Agent (review_doc) [agent]: Because…\n see L3', + 'rdc_1 (resolved) on What save does', + ' Ada: Why?', + ]); + }); + + it('says so when there are none', () => { + expect(formatThreads([])).toEqual(['no comments on this document yet']); + }); +}); + +describe('fetchComments', () => { + const dsn: ParsedDsn = { keyId: 'k', secret: 's', host: 'h.test', workspaceId: 'ws', scheme: 'https' }; + const target = { repo_key: `sha256:${'a'.repeat(64)}`, head_sha: 'b'.repeat(40), base_sha: 'c'.repeat(40) }; + + it('explains a document that was never pushed, and a workspace that has not opted in', async () => { + const answer = (code: string, status: number) => (async () => new Response(JSON.stringify({ status: 'error', code, error: 'x' }), { status })) as unknown as typeof fetch; + await expect(fetchComments(dsn, target, answer('review_doc_not_found', 404))).rejects.toThrow(/vg review doc --base --push/); + await expect(fetchComments(dsn, target, answer('review_doc_upload_disabled', 403))).rejects.toThrow(/workspace admin/); + }); +}); diff --git a/src/review/doc-comments.ts b/src/review/doc-comments.ts new file mode 100644 index 0000000..8f6e0df --- /dev/null +++ b/src/review/doc-comments.ts @@ -0,0 +1,110 @@ +/** + * Comments on a pushed review document, for the agent that wrote the change. + * + * People comment on blocks on the Review run page in Vibgrate Cloud. An agent + * reads the open threads and answers them here (`vg review doc --comments`, + * `--reply`, or the `review_doc` MCP tool), with the workspace DSN. Cloud + * stores every reply sent this way as an agent's, and an agent can only + * answer: it cannot start or resolve a thread. + */ + +import { CliError, ExitCode } from '../util/exit.js'; +import { parseDsn } from '../reporting/commands/push.js'; +import { resolveDsn } from '../reporting/credentials.js'; +import type { ReviewDoc } from './doc.js'; +import { postIngest, type ParsedDsn } from './push.js'; + +export interface DocTarget { + repo_key: string; + head_sha: string; + base_sha: string; +} + +export interface DocComment { + id: string; + blockId: string; + blockTitle: string | null; + parentId: string | null; + authorKind: 'person' | 'agent'; + authorName: string; + body: string; + createdAt: string; + resolvedAt: string | null; +} + +export interface DocThread { + id: string; + blockId: string; + blockTitle: string | null; + resolved: boolean; + comments: DocComment[]; +} + +/** The pushed document a local one corresponds to: same repository, head and base. */ +export function targetOf(doc: Pick): DocTarget { + return { repo_key: doc.target.repo_key ?? '', head_sha: doc.target.head_sha, base_sha: doc.target.base_sha }; +} + +/** The DSN for Cloud calls, or a CliError saying how to provide one. */ +export function cloudDsn(explicit?: string): ParsedDsn { + const dsn = resolveDsn(explicit); + if (!dsn) throw new CliError('no DSN — run `vg login`, set VIBGRATE_DSN, or pass --dsn', ExitCode.USAGE_ERROR); + const parsed = parseDsn(dsn); + if (!parsed) throw new CliError('invalid DSN format (expected vibgrate+https://:@/)', ExitCode.USAGE_ERROR); + return parsed; +} + +function failure(res: { status: number; code?: string; detail?: string }): CliError { + if (res.code === 'review_doc_upload_disabled') { + return new CliError('this workspace does not accept review documents — a workspace admin can turn on Review documents in Vibgrate Cloud settings', ExitCode.ERROR); + } + if (res.code === 'review_doc_not_found') { + return new CliError('no review document was pushed for this change — run `vg review doc --base --push` first', ExitCode.NOT_FOUND); + } + return new CliError(`Vibgrate Cloud answered ${res.status} — ${res.detail ?? ''}`, ExitCode.ERROR); +} + +/** Group comments into threads, oldest first, each with its replies in order. */ +export function threadsOf(comments: DocComment[]): DocThread[] { + const threads = new Map(); + for (const c of comments) { + if (c.parentId === null) threads.set(c.id, { id: c.id, blockId: c.blockId, blockTitle: c.blockTitle, resolved: c.resolvedAt !== null, comments: [c] }); + } + for (const c of comments) if (c.parentId !== null) threads.get(c.parentId)?.comments.push(c); + return [...threads.values()]; +} + +export async function fetchComments(dsn: ParsedDsn, target: DocTarget, fetchImpl: typeof fetch = fetch): Promise<{ title: string; threads: DocThread[] }> { + const res = await postIngest(dsn, '/v1/ingest/review-doc/comments', target, fetchImpl); + if (!res.ok) throw failure(res); + const body = (res.json ?? {}) as { title?: unknown; comments?: unknown }; + const comments = Array.isArray(body.comments) ? (body.comments as DocComment[]) : []; + return { title: typeof body.title === 'string' ? body.title : '', threads: threadsOf(comments) }; +} + +export async function replyToComment( + dsn: ParsedDsn, + target: DocTarget, + parentId: string, + text: string, + author: string | undefined, + fetchImpl: typeof fetch = fetch, +): Promise { + const res = await postIngest(dsn, '/v1/ingest/review-doc/reply', { ...target, parent_id: parentId, body: text, ...(author ? { author } : {}) }, fetchImpl); + if (!res.ok) throw failure(res); + return (res.json as { comment: DocComment }).comment; +} + +/** Threads as terminal text: open ones first, each comment with who wrote it. */ +export function formatThreads(threads: DocThread[]): string[] { + if (threads.length === 0) return ['no comments on this document yet']; + const out: string[] = []; + const ordered = [...threads.filter((t) => !t.resolved), ...threads.filter((t) => t.resolved)]; + for (const t of ordered) { + out.push(`${t.id} ${t.resolved ? '(resolved) ' : ''}on ${t.blockTitle ?? t.blockId}`); + for (const c of t.comments) { + out.push(` ${c.authorKind === 'agent' ? `${c.authorName} [agent]` : c.authorName}: ${c.body.replace(/\n/g, '\n ')}`); + } + } + return out; +} diff --git a/src/review/doc-store.test.ts b/src/review/doc-store.test.ts new file mode 100644 index 0000000..fcb8784 --- /dev/null +++ b/src/review/doc-store.test.ts @@ -0,0 +1,290 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { DocScope } from './doc-build.js'; +import { DOC_SCHEMA, renderReviewDocMarkdown, type ReviewDoc } from './doc.js'; +import { + applyPatch, + checkDocument, + docIdFor, + documentHistory, + getDocument, + listDocuments, + openDocument, + outline, + patchDocument, + pruneExpired, + restoreVersion, + savedOrBuilt, + storeDir, +} from './doc-store.js'; + +/** + * Saved review documents, patched block by block, against a real git + * repository: atomic patches, version conflicts, provenance that cannot be + * forged, restore, and the retention window. + */ + +const git = (cwd: string, ...args: string[]) => { + const res = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (res.status !== 0) throw new Error(`git ${args.join(' ')}: ${res.stderr}`); + return res.stdout.trim(); +}; +const write = (root: string, rel: string, text: string) => { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), text); +}; +const lines = (n: number, tag: string) => Array.from({ length: n }, (_, i) => `export const ${tag}${i} = ${i};`).join('\n') + '\n'; +const at = (iso: string) => () => new Date(iso); +const tree: DocScope = { kind: 'change', base: null, in_place: false }; +const quiet = { findings: false, diagrams: false }; + +describe('saved review documents', () => { + const roots: string[] = []; + afterEach(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); + }); + + function repo(): string { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'vg-doc-store-'))); + roots.push(root); + git(root, 'init', '-q', '-b', 'main'); + git(root, 'config', 'user.email', 'test@example.com'); + git(root, 'config', 'user.name', 'Test'); + write(root, '.gitignore', '.vibgrate/\n'); + write(root, 'src/a.ts', lines(20, 'a')); + git(root, 'add', '-A'); + git(root, 'commit', '-q', '-m', 'base'); + write(root, 'src/a.ts', lines(20, 'a').replace('a3 = 3', 'a3 = validate(3)')); + return root; + } + + const whatWhy = (doc: ReviewDoc) => doc.sections.find((s) => s.kind === 'what_why')!.blocks[0].id!; + + it('opens one document per scope, and reopening returns the saved version', async () => { + const root = repo(); + const first = await openDocument(root, tree, { ...quiet, clock: at('2026-10-02T10:00:00Z') }); + expect(first).toMatchObject({ doc_id: docIdFor(tree), version: 1, built: true }); + expect(first.doc.revision).toEqual({ doc_id: first.doc_id, version: 1, by: 'vg', at: '2026-10-02T10:00:00.000Z' }); + expect(first.doc.schema_version).toBe(DOC_SCHEMA); + const again = await openDocument(root, tree, quiet); + expect(again).toMatchObject({ doc_id: first.doc_id, version: 1, built: false }); + expect(again.doc.digest).toBe(first.doc.digest); + expect(docIdFor({ kind: 'change', base: 'origin/main', in_place: false })).not.toBe(first.doc_id); + // Never committed by accident: the store is in vg's own .gitignore. + expect(fs.readFileSync(path.join(root, '.vibgrate', '.gitignore'), 'utf8')).toContain('review-docs/'); + }); + + it('a patch is saved as a new version, marked as the agent’s, with vg’s parts left as they were', async () => { + const root = repo(); + const { doc_id, doc } = await openDocument(root, tree, quiet); + const res = patchDocument( + root, + doc_id, + 1, + [ + { op: 'set_text', block: whatWhy(doc), text: 'Adds validation to `a3`, see [the change](head:src/a.ts#L4).' }, + { op: 'insert', section: 'requirements', block: { type: 'markdown', text: '- Reject invalid input before it is stored.' } }, + { op: 'set_title', title: 'Validate a3' }, + ], + at('2026-10-02T11:00:00Z'), + ); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.version).toBe(2); + expect(res.doc.title).toBe('Validate a3'); + expect(res.doc.generator.by).toBe('mixed'); + expect(res.doc.revision).toMatchObject({ version: 2, by: 'agent' }); + expect(res.doc.sections.map((s) => s.kind)).toEqual(['what_why', 'requirements', 'implementation']); + const req = res.doc.sections[1].blocks[0]; + expect(req).toMatchObject({ type: 'markdown', origin: 'agent' }); + expect(req.id).toMatch(/^blk_[0-9a-f]{12}$/); + // The rewritten block keeps its id, so anything pointing at it still does. + expect(res.doc.sections[0].blocks[0]).toMatchObject({ id: whatWhy(doc), origin: 'agent' }); + // vg's implementation section is untouched and unmarked. + expect(res.doc.sections[2]).toEqual(doc.sections.find((s) => s.kind === 'implementation')); + expect(renderReviewDocMarkdown(res.doc)).toContain('(written by an agent)'); + const h = documentHistory(root, doc_id); + expect(h.versions.map((v) => [v.version, v.by, v.summary])).toEqual([ + [2, 'agent', '3 operations'], + [1, 'vg', 'built'], + ]); + }); + + it('all or nothing: a pin that does not land saves nothing and says which', async () => { + const root = repo(); + const { doc_id, doc } = await openDocument(root, tree, quiet); + const res = patchDocument(root, doc_id, 1, [ + { op: 'set_title', title: 'renamed' }, + { op: 'set_text', block: whatWhy(doc), text: 'See [here](head:src/a.ts#L90-L99).' }, + ]); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(res.issues.map((i) => i.code)).toContain('pin_out_of_range'); + expect(getDocument(root, doc_id)).toMatchObject({ version: 1 }); + expect(getDocument(root, doc_id).doc.title).not.toBe('renamed'); + }); + + it('refuses a patch written against an older version', async () => { + const root = repo(); + const { doc_id } = await openDocument(root, tree, quiet); + expect(patchDocument(root, doc_id, 1, [{ op: 'set_title', title: 'one' }]).ok).toBe(true); + const late = patchDocument(root, doc_id, 1, [{ op: 'set_title', title: 'two' }]); + expect(late).toMatchObject({ ok: false, conflict: true, version: 2 }); + expect(getDocument(root, doc_id).doc.title).toBe('one'); + }); + + it('names unknown blocks and ops instead of guessing', async () => { + const root = repo(); + const { doc_id } = await openDocument(root, tree, quiet); + const res = patchDocument(root, doc_id, 1, [ + { op: 'remove', block: 'blk_nope' }, + { op: 'rewrite_everything' }, + { op: 'insert', section: 'appendix', block: { type: 'divider' } }, + ]); + expect(res.ok).toBe(false); + if (res.ok) return; + expect(res.errors).toEqual([ + 'ops[0] (remove): no block blk_nope', + 'ops[1] (rewrite_everything): unknown op — use set_title, set_text, insert, replace, remove, move or set_primary', + 'ops[2] (insert): section must be one of what_why, requirements, design, implementation', + ]); + }); + + it('restores an earlier version as a new one; history is never rewritten', async () => { + const root = repo(); + const { doc_id, doc } = await openDocument(root, tree, quiet); + patchDocument(root, doc_id, 1, [{ op: 'set_title', title: 'edited' }]); + const res = restoreVersion(root, doc_id, 1); + expect(res).toMatchObject({ ok: true, version: 3 }); + if (!res.ok) return; + expect(res.doc.title).toBe(doc.title); + expect(res.doc.revision).toMatchObject({ version: 3, by: 'vg' }); + expect(documentHistory(root, doc_id).versions.map((v) => v.summary)).toEqual(['restored version 1', '1 operation', 'built']); + expect(getDocument(root, doc_id, 2).doc.title).toBe('edited'); + }); + + it('rebuilds when the commits move, keeping the edited versions in history', async () => { + const root = repo(); + const { doc_id } = await openDocument(root, tree, quiet); + patchDocument(root, doc_id, 1, [{ op: 'set_title', title: 'edited' }]); + git(root, 'commit', '-q', '-am', 'step'); + write(root, 'src/a.ts', lines(21, 'a')); + const moved = await openDocument(root, tree, quiet); + expect(moved).toMatchObject({ doc_id, version: 3, built: true }); + expect(moved.reason).toMatch(/written for .*the change is/); + expect(checkDocument(root, doc_id)).toMatchObject({ valid: true, issues: [] }); + expect(getDocument(root, doc_id, 2).doc.title).toBe('edited'); + }); + + it('reading never saves: the saved version while it matches, a fresh build otherwise', async () => { + const root = repo(); + expect((await savedOrBuilt(root, tree, quiet)).saved).toBeNull(); + expect(listDocuments(root)).toEqual([]); + const { doc_id } = await openDocument(root, tree, quiet); + patchDocument(root, doc_id, 1, [{ op: 'set_title', title: 'edited' }]); + const shown = await savedOrBuilt(root, tree, quiet); + expect(shown).toMatchObject({ saved: { doc_id, version: 2 } }); + expect(shown.doc.title).toBe('edited'); + git(root, 'commit', '-q', '-am', 'step'); + const moved = await savedOrBuilt(root, tree, quiet); + expect(moved.saved).toBeNull(); + expect(moved.note).toMatch(/version 2\) was written for an earlier commit/); + expect(documentHistory(root, doc_id).current).toBe(2); + }); + + it('deletes a document not updated for 365 days, the Repository retention window', async () => { + const root = repo(); + const { doc_id } = await openDocument(root, tree, { ...quiet, clock: at('2026-01-01T00:00:00Z') }); + expect(pruneExpired(root, new Date('2026-12-31T00:00:00Z'))).toEqual([]); + expect(pruneExpired(root, new Date('2027-01-02T00:00:00Z'))).toEqual([doc_id]); + expect(listDocuments(root)).toEqual([]); + expect(fs.existsSync(path.join(storeDir(root), doc_id))).toBe(false); + }); +}); + +describe('applyPatch: provenance cannot be forged', () => { + const pin = (start: number, end = start) => ({ side: 'head' as const, path: 'src/a.ts', start, end }); + const doc = (): ReviewDoc => ({ + schema_version: DOC_SCHEMA, + title: 't', + target: { repo_key: null, base_sha: 'a', head_sha: 'b', merge_base: null, dirty_tree_hash: null }, + sections: [ + { + kind: 'design', + blocks: [ + { + type: 'call_stack_diff', + id: 'blk_stack', + title: 'How save is reached', + primary: true, + base_status: 'not_computed', + base: [], + head: [ + { key: 'main', label: 'main', pin: pin(1, 3), origin: 'graph' }, + { key: 'save', parent_key: 'main', label: 'save', pin: pin(4, 9), origin: 'graph' }, + ], + }, + ], + }, + ], + groups_digest: null, + generator: { by: 'vg', notes: [] }, + }); + + it('an edited frame becomes the agent’s; an untouched one stays graph-derived', () => { + const d = doc(); + const stack = d.sections[0].blocks[0] as Extract; + const edited = { ...stack, head: [stack.head[0], { ...stack.head[1], label: 'save (persists the order)' }] }; + const res = applyPatch(d, [{ op: 'replace', block: 'blk_stack', with: edited }]); + expect(res.errors).toEqual([]); + const head = (res.doc.sections[0].blocks[0] as typeof stack).head; + expect(head.map((f) => f.origin)).toEqual(['graph', 'agent']); + expect(res.doc.sections[0].blocks[0].id).toBe('blk_stack'); + // It still said "graph" for the frame it edited; vg says what it did about that. + expect(res.notes).toEqual(['1 element marked origin "graph" is new or changed, not what vg derived, so it is recorded as "agent"']); + }); + + it('an agent cannot claim origin graph for what it drew', () => { + const res = applyPatch(doc(), [ + { + op: 'insert', + section: 'design', + block: { + type: 'flow', + title: 'What save does', + nodes: [ + { key: 'v', label: 'validate', pins: [pin(4)], origin: 'graph' }, + { key: 'p', label: 'persist', pins: [pin(5)] }, + ], + edges: [{ from: 'v', to: 'p', origin: 'graph' }], + }, + }, + { op: 'set_primary', block: 'blk_stack' }, + ]); + expect(res.errors).toEqual([]); + const flow = res.doc.sections[0].blocks[1] as { nodes: { origin: string }[]; edges: { origin: string }[]; id: string }; + expect(flow.nodes.map((n) => n.origin)).toEqual(['agent', 'agent']); + expect(flow.edges.map((e) => e.origin)).toEqual(['agent']); + expect(flow.id).toMatch(/^blk_/); + expect(res.notes).toEqual(['2 elements marked origin "graph" are new or changed, not what vg derived, so they are recorded as "agent"']); + }); + + it('moving the primary leaves exactly one, and removing the last block drops the section', () => { + const withFlow = applyPatch(doc(), [{ op: 'insert', section: 'design', block: { type: 'flow', title: 'f', nodes: [{ key: 'a', label: 'a', pins: [pin(1)] }], edges: [] } }]).doc; + const flowId = withFlow.sections[0].blocks[1].id!; + const moved = applyPatch(withFlow, [{ op: 'set_primary', block: flowId }]).doc; + expect(moved.sections[0].blocks.map((b) => Boolean((b as { primary?: boolean }).primary))).toEqual([false, true]); + const gone = applyPatch(doc(), [{ op: 'remove', block: 'blk_stack' }]).doc; + expect(gone.sections).toEqual([]); + }); + + it('outlines a document by block id, type and size', () => { + expect(outline(doc())).toEqual({ + title: 't', + sections: [{ kind: 'design', blocks: [{ id: 'blk_stack', type: 'call_stack_diff', title: 'How save is reached', primary: true, chars: expect.any(Number) }] }], + }); + }); +}); diff --git a/src/review/doc-store.ts b/src/review/doc-store.ts new file mode 100644 index 0000000..b2cce0c --- /dev/null +++ b/src/review/doc-store.ts @@ -0,0 +1,616 @@ +/** + * Saved review documents an agent or a person can edit, block by block. + * + * `vg review doc` writes a document and forgets it. This module keeps one per + * scope (the change, or one VG Code chat) under `.vibgrate/review-docs/`, with + * every version kept, so an agent can open it, patch a block by id, check it, + * and a reader can see who wrote what and go back to any earlier version. + * + * Rules, enforced here rather than trusted to the caller: + * + * - A patch is atomic. Every operation applies, and the whole document then + * passes the same validation `vg review doc --check` runs (schema, section + * order, one primary diagram, every pin lands on the reviewed change), or + * nothing is saved and the issues come back. + * - Provenance cannot be forged. Text an agent writes is marked + * `origin: "agent"`. A diagram element keeps `origin: "graph"` only when it + * is unchanged from what vg derived; anything new or edited becomes + * `agent`, whatever the patch claimed. + * - Writes never lose work. A patch or a restore adds a version; nothing is + * overwritten. Two writers are kept apart by the version number: a patch + * names the version it was written against, and a stale one is refused. + * - The store holds only Vibgrate's own state, never the repository's files. + * + * Retention (GUARDRAILS §1.7): a review document is derived from the source + * plus words written about it, so it inherits the **Repository** schedule + * (365 days). A document not updated for 365 days is deleted the next time + * any document is saved. + */ + +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { ensureVibgrateGitignore } from '../engine/artifacts.js'; +import { buildDocument, resolveScope, scopeResolver, type BuildOptions, type DocScope, type ResolvedScope } from './doc-build.js'; +import { + blockId, + SECTION_ORDER, + sealReviewDoc, + targetIssues, + validateReviewDoc, + type DocBlock, + type DocIssue, + type DocSection, + type PinResolver, + type ReviewDoc, + type SectionKind, +} from './doc.js'; + +export const STORE_SCHEMA = 'vg.review.docstore.v1' as const; +/** Repository retention (GUARDRAILS §1.7): 365 days since the last update. */ +export const RETENTION_DAYS = 365; +/** Operations one patch may carry. */ +export const MAX_OPS = 50; +/** Versions kept per document; the oldest go first, the current never. */ +export const MAX_VERSIONS = 200; + +export interface VersionEntry { + version: number; + at: string; + by: 'vg' | 'agent'; + /** What this version did, in a few words: "built", "3 operations", "restored version 2". */ + summary: string; + digest: string; +} + +export interface StoreMeta { + schema: typeof STORE_SCHEMA; + doc_id: string; + scope: DocScope; + current: number; + updated_at: string; + versions: VersionEntry[]; +} + +export type Clock = () => Date; +const systemClock: Clock = () => new Date(); + +export function storeDir(mainRoot: string): string { + return path.join(mainRoot, '.vibgrate', 'review-docs'); +} + +/** One document per scope: the same scope always opens the same document. */ +export function docIdFor(scope: DocScope): string { + const key = + scope.kind === 'change' + ? `change\0${scope.base ?? ''}\0${scope.in_place ? 1 : 0}` + : `session\0${scope.session}\0${scope.base ?? ''}`; + return `rd_${createHash('sha256').update(key).digest('hex').slice(0, 12)}`; +} + +function validId(id: string): boolean { + return /^rd_[0-9a-f]{12}$/.test(id); +} + +function docDir(mainRoot: string, id: string): string { + if (!validId(id)) throw new Error(`not a review document id: ${id}`); + return path.join(storeDir(mainRoot), id); +} + +function writeAtomic(file: string, text: string): void { + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmp, text); + fs.renameSync(tmp, file); +} + +export function readMeta(mainRoot: string, id: string): StoreMeta | null { + if (!validId(id)) return null; + try { + const meta = JSON.parse(fs.readFileSync(path.join(docDir(mainRoot, id), 'meta.json'), 'utf8')) as StoreMeta; + return meta.schema === STORE_SCHEMA && meta.doc_id === id ? meta : null; + } catch { + return null; + } +} + +export function readVersion(mainRoot: string, id: string, version: number): ReviewDoc | null { + if (!validId(id) || !Number.isInteger(version) || version < 1) return null; + try { + return JSON.parse(fs.readFileSync(path.join(docDir(mainRoot, id), `v${version}.json`), 'utf8')) as ReviewDoc; + } catch { + return null; + } +} + +/** Every saved document, newest first. */ +export function listDocuments(mainRoot: string): StoreMeta[] { + let names: string[] = []; + try { + names = fs.readdirSync(storeDir(mainRoot)); + } catch { + return []; + } + return names + .map((n) => (validId(n) ? readMeta(mainRoot, n) : null)) + .filter((m): m is StoreMeta => m !== null) + .sort((a, b) => b.updated_at.localeCompare(a.updated_at)); +} + +/** Delete documents past the retention window. Best-effort; never throws. */ +export function pruneExpired(mainRoot: string, now: Date): string[] { + const cutoff = now.getTime() - RETENTION_DAYS * 86_400_000; + const removed: string[] = []; + for (const m of listDocuments(mainRoot)) { + if (Date.parse(m.updated_at) < cutoff) { + try { + fs.rmSync(docDir(mainRoot, m.doc_id), { recursive: true, force: true }); + removed.push(m.doc_id); + } catch { + /* next save tries again */ + } + } + } + return removed; +} + +export class VersionConflict extends Error { + constructor(readonly current: number) { + super(`the document is at version ${current}`); + } +} + +/** + * Save `doc` as the next version. `expect` is the version the writer read; + * a writer that read an older one gets a VersionConflict and nothing is + * written. The version file is created exclusively, so two writers racing on + * the same number cannot both win. + */ +function saveVersion( + mainRoot: string, + scope: DocScope, + doc: ReviewDoc, + by: 'vg' | 'agent', + summary: string, + expect: number | null, + clock: Clock, +): { doc: ReviewDoc; version: number } { + const id = docIdFor(scope); + const dir = docDir(mainRoot, id); + fs.mkdirSync(dir, { recursive: true }); + // Saved documents are working state, never part of the change they describe. + ensureVibgrateGitignore(mainRoot); + const meta = readMeta(mainRoot, id); + const current = meta?.current ?? 0; + if (expect !== null && expect !== current) throw new VersionConflict(current); + const version = current + 1; + const at = clock().toISOString(); + const sealed = sealReviewDoc({ ...doc, revision: { doc_id: id, version, by, at } }); + try { + fs.writeFileSync(path.join(dir, `v${version}.json`), `${JSON.stringify(sealed, null, 2)}\n`, { flag: 'wx' }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') throw new VersionConflict(version); + throw err; + } + const versions = [...(meta?.versions ?? []), { version, at, by, summary, digest: sealed.digest ?? '' }]; + // Bound the history; the current version is always kept. + while (versions.length > MAX_VERSIONS) { + const old = versions.shift()!; + fs.rmSync(path.join(dir, `v${old.version}.json`), { force: true }); + } + writeAtomic(path.join(dir, 'meta.json'), `${JSON.stringify({ schema: STORE_SCHEMA, doc_id: id, scope, current: version, updated_at: at, versions } satisfies StoreMeta, null, 2)}\n`); + pruneExpired(mainRoot, clock()); + return { doc: sealed, version }; +} + +// ─── Patch operations ─────────────────────────────────────────────────────── + +export type PatchOp = + | { op: 'set_title'; title: string } + | { op: 'set_text'; block: string; text: string } + | { op: 'insert'; section: SectionKind; block: DocBlock; after?: string; at?: 'start' | 'end' } + | { op: 'replace'; block: string; with: DocBlock } + | { op: 'remove'; block: string } + | { op: 'move'; block: string; section: SectionKind; after?: string; at?: 'start' | 'end' } + | { op: 'set_primary'; block: string }; + +export interface PatchResult { + doc: ReviewDoc; + /** Operation-level problems (unknown block, wrong type). Any one rejects the patch. */ + errors: string[]; + /** Things vg changed about what the agent sent, so it knows (e.g. origin it may not claim). */ + notes: string[]; +} + +const DIAGRAMS = new Set(['flow', 'sequence', 'call_stack_diff', 'data_store', 'system_map', 'code_peek']); +const TEXT_BLOCKS = new Set(['markdown', 'callout']); + +type Obj = Record; + +function stableStringify(v: unknown): string { + if (Array.isArray(v)) return `[${v.map(stableStringify).join(',')}]`; + if (v && typeof v === 'object') { + return `{${Object.keys(v as Obj) + .sort() + .map((k) => `${JSON.stringify(k)}:${stableStringify((v as Obj)[k])}`) + .join(',')}}`; + } + return JSON.stringify(v); +} + +function sameIgnoringOrigin(a: Obj, b: Obj): boolean { + const { origin: _a, ...ra } = a; + const { origin: _b, ...rb } = b; + return stableStringify(ra) === stableStringify(rb); +} + +/** The element arrays of each diagram type, and how an element is recognised across versions. */ +const ELEMENT_LISTS: Record string }[]> = { + flow: [ + { key: 'nodes', identity: (e) => `n:${String(e.key)}` }, + { key: 'edges', identity: (e) => `e:${String(e.from)}>${String(e.to)}:${String(e.kind ?? '')}` }, + ], + sequence: [{ key: 'steps', identity: (e) => `s:${String(e.from)}>${String(e.to)}:${String(e.label)}` }], + call_stack_diff: [ + { key: 'base', identity: (e) => `b:${String(e.key ?? stableStringify(e.pin))}` }, + { key: 'head', identity: (e) => `h:${String(e.key ?? stableStringify(e.pin))}` }, + ], + system_map: [ + { key: 'elements', identity: (e) => `m:${String(e.path)}` }, + { key: 'relationships', identity: (e) => `r:${String(e.from)}>${String(e.to)}:${String(e.kind)}` }, + ], +}; + +/** + * Mark what the agent wrote. Text blocks become `origin: "agent"`. A diagram + * element stays `graph` only when the block it replaces has the same element, + * unchanged, derived by vg; every other element is `agent`. Returns how many + * elements claimed `graph` and were not allowed to. + */ +function markAgent(next: DocBlock, prev: DocBlock | null): { block: DocBlock; refused: number } { + const b = JSON.parse(JSON.stringify(next)) as Obj; + delete b.id; + let refused = 0; + const lists = ELEMENT_LISTS[String(b.type)]; + if (!lists) { + if (!(prev && sameIgnoringOrigin(b, prev as unknown as Obj) && (prev as { origin?: string }).origin !== 'agent')) { + if (b.origin === 'graph') refused += 1; + b.origin = 'agent'; + } + return { block: b as unknown as DocBlock, refused }; + } + for (const { key, identity } of lists) { + const items = Array.isArray(b[key]) ? (b[key] as unknown[]) : []; + const before = new Map(); + if (prev && prev.type === next.type) { + for (const e of ((prev as unknown as Obj)[key] as Obj[] | undefined) ?? []) before.set(identity(e), e); + } + b[key] = items.map((raw) => { + if (!raw || typeof raw !== 'object') return raw; + const e = { ...(raw as Obj) }; + const old = before.get(identity(e)); + const derived = old && old.origin === 'graph' && sameIgnoringOrigin(old, e); + if (!derived) { + if (e.origin === 'graph') refused += 1; + e.origin = 'agent'; + } else { + e.origin = 'graph'; + } + return e; + }); + } + return { block: b as unknown as DocBlock, refused }; +} + +function findBlock(doc: ReviewDoc, id: string): { section: DocSection; index: number } | null { + for (const section of doc.sections) { + const index = section.blocks.findIndex((b) => b.id === id); + if (index >= 0) return { section, index }; + } + return null; +} + +function sectionFor(doc: ReviewDoc, kind: SectionKind): DocSection { + let s = doc.sections.find((x) => x.kind === kind); + if (!s) { + s = { kind, blocks: [] }; + doc.sections.push(s); + doc.sections.sort((a, b) => SECTION_ORDER.indexOf(a.kind) - SECTION_ORDER.indexOf(b.kind)); + } + return s; +} + +function place(section: DocSection, block: DocBlock, after: string | undefined, at: 'start' | 'end' | undefined): string | null { + if (after) { + const i = section.blocks.findIndex((b) => b.id === after); + if (i < 0) return `no block ${after} in ${section.kind}`; + section.blocks.splice(i + 1, 0, block); + } else if (at === 'start') { + section.blocks.unshift(block); + } else { + section.blocks.push(block); + } + return null; +} + +function freshId(doc: ReviewDoc, block: DocBlock): string { + const taken = new Set(doc.sections.flatMap((s) => s.blocks.map((b) => b.id))); + const base = blockId(block); + let id = base; + for (let n = 1; taken.has(id); n++) id = `${base}_${n}`; + return id; +} + +/** + * Apply operations to a copy of `doc`. Pure: no disk, no git. The caller + * validates the result before saving it. + */ +export function applyPatch(doc: ReviewDoc, ops: unknown): PatchResult { + const out = JSON.parse(JSON.stringify(doc)) as ReviewDoc; + const errors: string[] = []; + let refused = 0; + if (!Array.isArray(ops) || ops.length === 0) return { doc: out, errors: ['ops must be a non-empty array'], notes: [] }; + if (ops.length > MAX_OPS) return { doc: out, errors: [`at most ${MAX_OPS} operations per patch`], notes: [] }; + const sectionOk = (k: unknown): k is SectionKind => typeof k === 'string' && (SECTION_ORDER as readonly string[]).includes(k); + const blockOk = (b: unknown): b is DocBlock => !!b && typeof b === 'object' && typeof (b as Obj).type === 'string'; + + ops.forEach((raw, i) => { + const op = (raw ?? {}) as Obj; + const at = `ops[${i}] (${String(op.op)})`; + const target = typeof op.block === 'string' ? findBlock(out, op.block) : null; + switch (op.op) { + case 'set_title': + if (typeof op.title !== 'string' || !op.title.trim()) errors.push(`${at}: title must be a non-empty string`); + else out.title = op.title.trim().slice(0, 200); + return; + case 'set_text': { + if (!target) return void errors.push(`${at}: no block ${String(op.block)}`); + const b = target.section.blocks[target.index]; + if (!TEXT_BLOCKS.has(b.type)) return void errors.push(`${at}: ${b.type} blocks have no text; use replace`); + if (typeof op.text !== 'string') return void errors.push(`${at}: text must be a string`); + target.section.blocks[target.index] = { ...b, text: op.text, origin: 'agent' } as DocBlock; + return; + } + case 'insert': { + if (!sectionOk(op.section)) return void errors.push(`${at}: section must be one of ${SECTION_ORDER.join(', ')}`); + if (!blockOk(op.block)) return void errors.push(`${at}: block must be an object with a type`); + const marked = markAgent(op.block, null); + refused += marked.refused; + const block = { ...marked.block, id: freshId(out, marked.block) } as DocBlock; + const problem = place(sectionFor(out, op.section), block, typeof op.after === 'string' ? op.after : undefined, op.at === 'start' ? 'start' : undefined); + if (problem) errors.push(`${at}: ${problem}`); + return; + } + case 'replace': { + if (!target) return void errors.push(`${at}: no block ${String(op.block)}`); + if (!blockOk(op.with)) return void errors.push(`${at}: with must be an object with a type`); + const prev = target.section.blocks[target.index]; + const marked = markAgent(op.with, prev); + refused += marked.refused; + // The id stays, so anything that referred to the block still does. + target.section.blocks[target.index] = { ...marked.block, id: prev.id } as DocBlock; + return; + } + case 'remove': + if (!target) return void errors.push(`${at}: no block ${String(op.block)}`); + target.section.blocks.splice(target.index, 1); + return; + case 'move': { + if (!target) return void errors.push(`${at}: no block ${String(op.block)}`); + if (!sectionOk(op.section)) return void errors.push(`${at}: section must be one of ${SECTION_ORDER.join(', ')}`); + const [b] = target.section.blocks.splice(target.index, 1); + const problem = place(sectionFor(out, op.section), b, typeof op.after === 'string' ? op.after : undefined, op.at === 'start' ? 'start' : undefined); + if (problem) errors.push(`${at}: ${problem}`); + return; + } + case 'set_primary': { + if (!target) return void errors.push(`${at}: no block ${String(op.block)}`); + if (target.section.kind !== 'design') return void errors.push(`${at}: only a design block can be primary`); + if (!DIAGRAMS.has(target.section.blocks[target.index].type)) return void errors.push(`${at}: only a diagram can be primary`); + target.section.blocks = target.section.blocks.map((b, j) => { + const { primary: _p, ...rest } = b as DocBlock & { primary?: boolean }; + return (j === target.index ? { ...rest, primary: true } : rest) as DocBlock; + }); + return; + } + default: + errors.push(`${at}: unknown op — use set_title, set_text, insert, replace, remove, move or set_primary`); + } + }); + + out.sections = out.sections.filter((s) => s.blocks.length > 0); + // A design section whose only diagram is not marked primary: it is the primary. + const design = out.sections.find((s) => s.kind === 'design'); + if (design) { + const diagrams = design.blocks.filter((b) => DIAGRAMS.has(b.type)); + if (diagrams.length === 1 && !diagrams.some((b) => (b as { primary?: boolean }).primary)) { + (diagrams[0] as { primary?: boolean }).primary = true; + } + } + if (out.generator.by === 'vg') out.generator = { ...out.generator, by: 'mixed' }; + const notes = refused > 0 ? [`${refused} element${refused === 1 ? '' : 's'} marked origin "graph" ${refused === 1 ? 'is' : 'are'} new or changed, not what vg derived, so ${refused === 1 ? 'it is' : 'they are'} recorded as "agent"`] : []; + return { doc: out, errors, notes }; +} + +// ─── The operations an agent or the CLI calls ─────────────────────────────── + +export interface Outline { + title: string; + sections: { kind: SectionKind; blocks: { id: string; type: string; title?: string; primary?: boolean; origin?: string; chars: number }[] }[]; +} + +/** A compact map of the document: what an agent needs to address blocks without reading them all. */ +export function outline(doc: ReviewDoc): Outline { + return { + title: doc.title, + sections: doc.sections.map((s) => ({ + kind: s.kind, + blocks: s.blocks.map((b) => { + const o = b as DocBlock & { title?: string; primary?: boolean; origin?: string; caption?: string }; + return { + id: b.id ?? '', + type: b.type, + ...(o.title || o.caption ? { title: o.title ?? o.caption } : {}), + ...(o.primary ? { primary: true } : {}), + ...(o.origin ? { origin: o.origin } : {}), + chars: JSON.stringify(b).length, + }; + }), + })), + }; +} + +export interface OpenResult { + doc_id: string; + version: number; + doc: ReviewDoc; + /** True when this call built a new version from the change (first open, `fresh`, or the change moved). */ + built: boolean; + /** Why the saved version was not reused, when it was not. */ + reason?: string; +} + +/** + * Open the document for a scope. The saved one is returned while it still + * describes the change; otherwise (none saved, `fresh`, or the commits moved) + * vg builds the deterministic first pass and saves it as a new version, so the + * earlier versions stay in the history. + */ +export async function openDocument( + mainRoot: string, + scope: DocScope, + o: BuildOptions & { fresh?: boolean; clock?: Clock } = {}, +): Promise { + const clock = o.clock ?? systemClock; + const resolved = resolveScope(mainRoot, scope); + const id = docIdFor(resolved.scope); + const meta = readMeta(mainRoot, id); + if (meta && !o.fresh) { + const saved = readVersion(mainRoot, id, meta.current); + if (saved) { + const stale = targetIssues(saved, resolved.change); + if (stale.length === 0) return { doc_id: id, version: meta.current, doc: saved, built: false }; + const built = await buildDocument(mainRoot, resolved.scope, o); + const saved2 = saveVersion(mainRoot, resolved.scope, built.doc, 'vg', 'rebuilt: the change moved', null, clock); + return { doc_id: id, version: saved2.version, doc: saved2.doc, built: true, reason: stale[0].message }; + } + } + const built = await buildDocument(mainRoot, resolved.scope, o); + const saved = saveVersion(mainRoot, resolved.scope, built.doc, 'vg', meta ? 'rebuilt on request' : 'built', null, clock); + return { doc_id: id, version: saved.version, doc: saved.doc, built: true }; +} + +function current(mainRoot: string, id: string): { meta: StoreMeta; doc: ReviewDoc } { + const meta = readMeta(mainRoot, id); + const doc = meta ? readVersion(mainRoot, id, meta.current) : null; + if (!meta || !doc) throw new Error(`no saved review document ${id} — open one first`); + return { meta, doc }; +} + +/** Issues with a document against the change as it is now: target, schema, and every pin. */ +/** + * Check a document against the change. A multi-repo document's pins in other + * repositories are checked against `others` (their checkouts, keyed by repo + * key, from `--also`); a repository with no checkout given is named in one + * issue rather than failing every pin in it. + */ +export function checkAgainstChange(doc: ReviewDoc, resolved: ResolvedScope, others: ReadonlyMap = new Map()): DocIssue[] { + const byId = new Map(); + const missing: DocIssue[] = []; + for (const [i, r] of (doc.repos ?? []).entries()) { + const resolver = others.get(r.repo_key); + if (resolver) byId.set(r.id, resolver); + else missing.push({ path: `$.repos[${i}]`, code: 'repo_not_checked', message: `pins in ${r.id} were not checked — pass --also to check them` }); + } + const own = scopeResolver(resolved); + // A repository with no checkout given is reported once above, so its pins are not + // each failed here: they are treated as landing (any line count), never as checked. + const issues = validateReviewDoc(doc, (side, p, repo) => + !repo ? own(side, p) : byId.has(repo) ? byId.get(repo)!(side, p) : Number.MAX_SAFE_INTEGER, + ); + return [...targetIssues(doc, resolved.change), ...missing, ...issues]; +} + +export function checkDocument(mainRoot: string, id: string): { doc_id: string; version: number; valid: boolean; issues: DocIssue[] } { + const { meta, doc } = current(mainRoot, id); + const issues = checkAgainstChange(doc, resolveScope(mainRoot, meta.scope)); + return { doc_id: id, version: meta.current, valid: issues.length === 0, issues }; +} + +/** + * What a reader should see for a scope: the saved document when one still + * describes the change, else a fresh build. Reading never saves; only an + * explicit open or an agent's patch writes to the store. + */ +export async function savedOrBuilt( + mainRoot: string, + scope: DocScope, + o: BuildOptions = {}, +): Promise<{ doc: ReviewDoc; saved: { doc_id: string; version: number } | null; note?: string }> { + const resolved = resolveScope(mainRoot, scope); + const id = docIdFor(resolved.scope); + const meta = readMeta(mainRoot, id); + const saved = meta ? readVersion(mainRoot, id, meta.current) : null; + if (meta && saved) { + const stale = targetIssues(saved, resolved.change); + if (stale.length === 0) return { doc: saved, saved: { doc_id: id, version: meta.current } }; + const built = await buildDocument(mainRoot, resolved.scope, o); + return { doc: built.doc, saved: null, note: `saved document ${id} (version ${meta.current}) was written for an earlier commit — ${stale[0].message}` }; + } + return { doc: (await buildDocument(mainRoot, resolved.scope, o)).doc, saved: null }; +} + +export type PatchOutcome = + | { ok: true; doc_id: string; version: number; doc: ReviewDoc; notes: string[] } + | { ok: false; doc_id: string; version: number; conflict?: boolean; errors: string[]; issues: DocIssue[] }; + +/** + * Apply a patch written against version `expect`. All or nothing: an unknown + * block, an invalid result or a pin that does not land saves nothing and says + * exactly why. + */ +export function patchDocument(mainRoot: string, id: string, expect: number, ops: unknown, clock: Clock = systemClock): PatchOutcome { + const { meta, doc } = current(mainRoot, id); + if (expect !== meta.current) { + return { ok: false, doc_id: id, version: meta.current, conflict: true, errors: [`written against version ${expect}; the document is at version ${meta.current} — read it again and reapply`], issues: [] }; + } + const result = applyPatch(doc, ops); + if (result.errors.length > 0) return { ok: false, doc_id: id, version: meta.current, errors: result.errors, issues: [] }; + const issues = checkAgainstChange(result.doc, resolveScope(mainRoot, meta.scope)); + if (issues.length > 0) return { ok: false, doc_id: id, version: meta.current, errors: [], issues }; + try { + const n = Array.isArray(ops) ? ops.length : 0; + const saved = saveVersion(mainRoot, meta.scope, result.doc, 'agent', `${n} operation${n === 1 ? '' : 's'}`, expect, clock); + return { ok: true, doc_id: id, version: saved.version, doc: saved.doc, notes: result.notes }; + } catch (err) { + if (err instanceof VersionConflict) { + return { ok: false, doc_id: id, version: err.current, conflict: true, errors: ['another writer saved first — read the document again and reapply'], issues: [] }; + } + throw err; + } +} + +/** Make an earlier version current again, as a new version. History is never rewritten. */ +export function restoreVersion(mainRoot: string, id: string, version: number, clock: Clock = systemClock): PatchOutcome { + const { meta } = current(mainRoot, id); + const old = readVersion(mainRoot, id, version); + if (!old) return { ok: false, doc_id: id, version: meta.current, errors: [`no version ${version} of ${id}`], issues: [] }; + const issues = checkAgainstChange(old, resolveScope(mainRoot, meta.scope)); + if (issues.length > 0) return { ok: false, doc_id: id, version: meta.current, errors: [`version ${version} no longer matches the change`], issues }; + const { revision: _r, digest: _d, ...body } = old; + const prior = meta.versions.find((v) => v.version === version); + const saved = saveVersion(mainRoot, meta.scope, body as ReviewDoc, prior?.by ?? 'agent', `restored version ${version}`, meta.current, clock); + return { ok: true, doc_id: id, version: saved.version, doc: saved.doc, notes: [] }; +} + +export function documentHistory(mainRoot: string, id: string): { doc_id: string; scope: DocScope; current: number; versions: VersionEntry[] } { + const { meta } = current(mainRoot, id); + return { doc_id: id, scope: meta.scope, current: meta.current, versions: [...meta.versions].reverse() }; +} + +export function getDocument(mainRoot: string, id: string, version?: number): { doc_id: string; version: number; doc: ReviewDoc } { + const { meta, doc } = current(mainRoot, id); + if (version === undefined || version === meta.current) return { doc_id: id, version: meta.current, doc }; + const old = readVersion(mainRoot, id, version); + if (!old) throw new Error(`no version ${version} of ${id}`); + return { doc_id: id, version, doc: old }; +} diff --git a/src/review/doc.test.ts b/src/review/doc.test.ts new file mode 100644 index 0000000..b03d7fa --- /dev/null +++ b/src/review/doc.test.ts @@ -0,0 +1,407 @@ +import { describe, expect, it } from 'vitest'; +import type { ChangeSet, ChangedFile } from './git.js'; +import type { HaileProvider } from '../engine/haile/haile-provider.js'; +import { emptySignals, groupChangeSet, type DiffGroup } from './groups.js'; +import { + blockId, + buildReviewDoc, + DOC_SCHEMA, + markdownPinLinks, + parsePinLink, + pinLink, + renderReviewDocMarkdown, + sealReviewDoc, + targetIssues, + validateReviewDoc, + type DocBlock, + type PinResolver, + type ReviewDoc, +} from './doc.js'; +import type { AnalysisCapsule, ReviewFindings } from './schemas.js'; + +const files: ChangedFile[] = [ + { path: 'src/services/orders.ts', op: 'modified', addedLines: 4, removedLines: 1, hunks: [{ start: 10, end: 13 }] }, + { path: 'src/controllers/orders.ts', op: 'added', addedLines: 20, removedLines: 0, hunks: [{ start: 1, end: 20 }] }, + { path: 'src/legacy.ts', op: 'removed', addedLines: 0, removedLines: 12, hunks: [] }, + { path: 'src/orders.test.ts', op: 'modified', addedLines: 5, removedLines: 0, hunks: [{ start: 3, end: 7 }] }, +]; + +const change: ChangeSet = { + topLevel: '/repo', + baseSha: 'a'.repeat(40), + headSha: 'c'.repeat(40), + mergeBase: 'a'.repeat(40), + ref: null, + dirty: false, + dirtyTreeHash: null, + files, + remote: null, +}; + +/** head: 40 lines per surviving file; base: legacy.ts has 12, orders.ts 30. */ +const resolve: PinResolver = (side, p) => { + if (side === 'head') return p === 'src/legacy.ts' ? null : p.startsWith('src/') ? 40 : null; + return p === 'src/legacy.ts' ? 12 : p === 'src/services/orders.ts' ? 30 : null; +}; + +/** Stands in for the Architecture module: tests in their own group, the rest implementation. */ +const grouper: HaileProvider = { + version: () => 'test', + classify: () => null, + reviewGroups: (payload) => { + const files = (payload as { files: { path: string; op: string; added_lines: number; removed_lines: number }[] }).files; + const make = (kind: DiffGroup['kind'], list: typeof files): DiffGroup => ({ + id: `grp:${kind}`, + kind, + label: kind === 'tests' ? 'Tests' : 'Implementation', + area: null, + layer: null, + files: list.map((f) => ({ path: f.path, op: f.op as DiffGroup['files'][number]['op'], added_lines: f.added_lines, removed_lines: f.removed_lines, reason: 'test' })), + added_lines: list.reduce((n, f) => n + f.added_lines, 0), + removed_lines: list.reduce((n, f) => n + f.removed_lines, 0), + }); + const tests = files.filter((f) => f.path.includes('.test.')); + const impl = files.filter((f) => !f.path.includes('.test.')); + return { groups: [make('implementation', impl), ...(tests.length ? [make('tests', tests)] : [])] }; + }, +}; + +function built(extra: Partial[0]> = {}): ReviewDoc { + return buildReviewDoc({ change, groups: groupChangeSet(change, emptySignals(), grouper), repoKey: 'sha256:repo', resolve, ...extra }); +} + +function docWith(sections: ReviewDoc['sections']): ReviewDoc { + return { ...built(), sections }; +} + +const pin = (start = 1, end = start, p = 'src/services/orders.ts', side: 'head' | 'base' = 'head') => ({ side, path: p, start, end }); + +const flow = (over: Partial> = {}): DocBlock => ({ + type: 'flow', + title: 'Place order', + primary: true, + nodes: [ + { key: 'start', label: 'POST /orders', kind: 'terminal' }, + { key: 'valid', label: 'Valid?', kind: 'decision', pins: [pin(10, 11)] }, + { key: 'save', label: 'Save order', pins: [pin(12, 13)], status: 'added', origin: 'graph' }, + { key: 'reject', label: '400', kind: 'terminal' }, + ], + edges: [ + { from: 'start', to: 'valid' }, + { from: 'valid', to: 'save', kind: 'branch_true' }, + { from: 'valid', to: 'reject', kind: 'branch_false' }, + ], + ...over, +}); + +const codes = (doc: unknown, r: PinResolver | null = resolve) => validateReviewDoc(doc, r).map((i) => i.code); + +describe('buildReviewDoc — the deterministic first pass', () => { + it('validates against its own rules, pins included', () => { + expect(validateReviewDoc(built(), resolve)).toEqual([]); + }); + + it('is byte-identical across runs and seals a digest', () => { + expect(JSON.stringify(built())).toBe(JSON.stringify(built())); + expect(built().digest).toMatch(/^sha256:/); + expect(built().schema_version).toBe(DOC_SCHEMA); + }); + + it('writes what and why plus implementation, and no design it has not derived', () => { + const doc = built(); + expect(doc.sections.map((s) => s.kind)).toEqual(['what_why', 'implementation']); + expect(doc.generator.notes.join(' ')).toMatch(/design is left out/); + }); + + it('links every hunk on the head side and a removed file on the base side', () => { + const text = built().sections[1].blocks.map((b) => (b.type === 'markdown' ? b.text : '')).join('\n'); + expect(text).toContain('(head:src/services/orders.ts#L10-L13)'); + expect(text).toContain('(base:src/legacy.ts#L1-L12)'); + }); + + it('peels tests into their own list rather than implementation', () => { + const blocks = built().sections[1].blocks; + const last = blocks[blocks.length - 1]; + expect(last.type === 'markdown' && last.text).toMatch(/Peeled off[\s\S]*src\/orders\.test\.ts/); + }); + + it('cites findings with pinned evidence, and drops spans that do not land', () => { + const findings: ReviewFindings = { + schema_version: 'vg.review.findings.v1', + change_class: ['architecture'], + architecture_findings: [ + { + id: 'arch:skip:src/controllers/orders.ts', + kind: 'architecture', + severity: 'high', + confidence: 0.9, + claim: 'Controller calls the repository directly', + evidence_ids: ['edge:1', 'edge:2'], + target_alignment: 'regression', + remediation: 'Go through the service', + paths: ['src/controllers/orders.ts'], + }, + ], + security_findings: [], + unknowns: [], + required_checks: [], + }; + const capsule = { + evidence: [ + { id: 'edge:1', kind: 'graph_edge', path: 'src/controllers/orders.ts', start_line: 5, end_line: 6, protected_finding: false }, + { id: 'edge:2', kind: 'graph_edge', path: 'src/controllers/orders.ts', start_line: 90, end_line: 95, protected_finding: false }, + ], + } as unknown as AnalysisCapsule; + const doc = built({ findings, capsule }); + const callout = doc.sections[1].blocks.find((b) => b.type === 'callout'); + expect(callout).toMatchObject({ tone: 'risk', pins: [{ side: 'head', path: 'src/controllers/orders.ts', start: 5, end: 6 }] }); + expect(doc.generator.notes.join(' ')).toMatch(/1 finding evidence span did not land/); + expect(validateReviewDoc(doc, resolve)).toEqual([]); + }); + + it('without the Architecture module: every file listed for reading, and the install note', () => { + const doc = buildReviewDoc({ change, groups: groupChangeSet(change), repoKey: null, resolve }); + const text = doc.sections[1].blocks.map((b) => (b.type === 'markdown' ? b.text : '')).join('\n'); + expect(text).toContain('Changed files (not grouped)'); + expect(text).toContain('(head:src/services/orders.ts#L10-L13)'); + expect(text).not.toContain('Peeled off'); + expect(doc.generator.notes.join(' ')).toContain('vg module install arch'); + expect(validateReviewDoc(doc, resolve)).toEqual([]); + }); + + it('says plainly when there is nothing to review', () => { + const empty = { ...change, files: [] }; + const doc = buildReviewDoc({ change: empty, groups: groupChangeSet(empty), repoKey: null, resolve }); + expect(doc.sections).toHaveLength(1); + expect(validateReviewDoc(doc, resolve)).toEqual([]); + }); +}); + +describe('validateReviewDoc — the evidence rule', () => { + it('rejects a pin past the end of the file, on a missing file, or reaching outside the repo', () => { + const doc = docWith([ + { + kind: 'implementation', + blocks: [ + { type: 'code_peek', pin: pin(39, 41) }, + { type: 'code_peek', pin: pin(1, 1, 'lib/nope.ts') }, + { type: 'code_peek', pin: pin(1, 1, '../etc/passwd') }, + { type: 'code_peek', pin: { side: 'head', path: 'src/a.ts', start: 5, end: 2 } }, + ], + }, + ]); + expect(codes(doc)).toEqual(['pin_out_of_range', 'pin_missing_file', 'pin_path', 'pin_range']); + }); + + it('checks base-side pins against the base', () => { + const doc = docWith([{ kind: 'implementation', blocks: [{ type: 'code_peek', pin: pin(25, 31, 'src/services/orders.ts', 'base') }] }]); + expect(codes(doc)).toEqual(['pin_out_of_range']); + }); + + it('checks evidence links inside Markdown, and rejects malformed ones', () => { + const doc = docWith([ + { kind: 'what_why', blocks: [{ type: 'markdown', text: 'See [ok](head:src/a.ts#L2-L3), [bad](head:src/a.ts#L2-L99) and [junk](head:src/a.ts)' }] }, + ]); + expect(codes(doc)).toEqual(['pin_out_of_range', 'pin_link']); + }); + + it('skips pin landing checks without a resolver, but still checks shape', () => { + const doc = docWith([{ kind: 'implementation', blocks: [{ type: 'code_peek', pin: pin(999, 999) }] }]); + expect(codes(doc, null)).toEqual([]); + }); +}); + +describe('validateReviewDoc — document structure', () => { + it('enforces section order and no repeats', () => { + const md: DocBlock = { type: 'markdown', text: 'x' }; + expect(codes(docWith([{ kind: 'implementation', blocks: [md] }, { kind: 'what_why', blocks: [md] }]))).toEqual(['section_order']); + expect(codes(docWith([{ kind: 'what_why', blocks: [md] }, { kind: 'what_why', blocks: [md] }]))).toEqual(['duplicate_section']); + }); + + it('requires exactly one primary diagram in design, and none elsewhere', () => { + expect(codes(docWith([{ kind: 'design', blocks: [flow()] }]))).toEqual([]); + expect(codes(docWith([{ kind: 'design', blocks: [flow({ primary: false })] }]))).toEqual(['primary_diagram']); + expect(codes(docWith([{ kind: 'design', blocks: [flow(), flow()] }]))).toEqual(['primary_diagram']); + expect(codes(docWith([{ kind: 'implementation', blocks: [flow()] }]))).toEqual(['primary_outside_design']); + expect(codes(docWith([{ kind: 'design', blocks: [{ type: 'markdown', text: 'x', primary: true } as DocBlock] }]))).toContain('primary_type'); + }); + + it('rejects duplicate block ids and unknown block types', () => { + const doc = docWith([ + { kind: 'implementation', blocks: [{ type: 'markdown', id: 'a', text: 'x' }, { type: 'markdown', id: 'a', text: 'y' }, { type: 'chart' } as unknown as DocBlock] }, + ]); + expect(codes(doc)).toEqual(['duplicate_id', 'unknown_block']); + }); +}); + +describe('validateReviewDoc — diagrams', () => { + const design = (b: DocBlock) => docWith([{ kind: 'design', blocks: [b] }]); + + it('flow: dangling edges, duplicate keys, unpinned process nodes, branches from a non-decision', () => { + const f = flow({ + nodes: [ + { key: 'a', label: 'A', kind: 'terminal' }, + { key: 'a', label: 'dup', kind: 'terminal' }, + { key: 'p', label: 'does work' }, + ], + edges: [ + { from: 'a', to: 'ghost' }, + { from: 'a', to: 'p', kind: 'branch_true' }, + ], + }); + expect(codes(design(f))).toEqual(['duplicate_key', 'unpinned_node', 'dangling_edge', 'branch_from_non_decision']); + }); + + it('flow: caps nodes at 100', () => { + const nodes = Array.from({ length: 101 }, (_, i) => ({ key: `n${i}`, label: `N${i}`, kind: 'terminal' as const })); + expect(codes(design(flow({ nodes, edges: [] })))).toEqual(['too_many']); + }); + + it('sequence: steps between known actors, each pinned or explained', () => { + const s: DocBlock = { + type: 'sequence', + title: 'Checkout', + primary: true, + actors: [{ key: 'ui', label: 'Cart UI' }, { key: 'api', label: 'Orders API' }], + steps: [ + { from: 'ui', to: 'api', label: 'POST /orders', pins: [pin(1, 2, 'src/controllers/orders.ts')] }, + { from: 'api', to: 'ui', label: '201', style: 'return', note: 'framework serializes the response' }, + { from: 'api', to: 'db', label: 'insert' }, + ], + }; + expect(codes(design(s))).toEqual(['unknown_actor', 'unpinned_step']); + }); + + it('call_stack_diff: an uncomputed or absent before side must be empty, and statuses are checked', () => { + const c = (over: object): DocBlock => ({ type: 'call_stack_diff', title: 'x', primary: true, base: [], head: [{ key: 'a', pin: pin(1, 2) }], ...over }) as DocBlock; + expect(codes(design(c({ base_status: 'not_computed' })))).toEqual([]); + expect(codes(design(c({ base_status: 'absent', base: [{ pin: pin(1, 2, 'src/services/orders.ts', 'base') }] })))).toEqual(['base_status']); + expect(codes(design(c({ base_status: 'maybe' })))).toEqual(['enum']); + expect(codes(design(c({ head: [{ key: 'a', pin: pin(1, 2), status: 'new' }] })))).toEqual(['enum']); + }); + + it('call_stack_diff: parents must come first; keys unique per side', () => { + const c: DocBlock = { + type: 'call_stack_diff', + title: 'Order path', + primary: true, + base: [{ key: 'h', pin: pin(1, 2, 'src/services/orders.ts', 'base') }], + head: [ + { key: 'c', parent_key: 'h2', pin: pin(3, 4) }, + { key: 'h2', pin: pin(1, 2), via: { kind: 'queue', reason: 'outbox' } }, + { key: 'h2', pin: pin(5, 6) }, + ], + }; + expect(codes(design(c))).toEqual(['parent_order', 'duplicate_key']); + }); + + it('data_store: operations and foreign keys must resolve, nested fields by dotted path', () => { + const d: DocBlock = { + type: 'data_store', + title: 'Orders data', + primary: true, + actors: [{ key: 'svc', label: 'Order service' }], + stores: [ + { + key: 'pg', + label: 'Postgres', + storage: 'relational', + collections: [ + { key: 'users', label: 'users', fields: [{ key: 'id', label: 'id', data_type: 'uuid', primary_key: true }] }, + { + key: 'orders', + label: 'orders', + fields: [ + { key: 'user_id', label: 'user', data_type: 'uuid', references: { store: 'pg', collection: 'users', field: 'id' } }, + { key: 'addr', label: 'address', data_type: 'jsonb', fields: [{ key: 'city', label: 'city', data_type: 'text' }] }, + { key: 'bad', label: 'bad', data_type: 'uuid', references: { store: 'pg', collection: 'carts', field: 'id' } }, + ], + }, + ], + }, + ], + use_cases: [ + { + label: 'Place order', + operations: [ + { kind: 'write', store: 'pg', collection: 'orders', field: 'addr.city', actor: 'svc', label: 'insert', pin: pin(12, 13) }, + { kind: 'read', store: 'pg', collection: 'users', field: 'email', actor: 'svc', label: 'lookup', pin: pin(10, 10) }, + { kind: 'read', store: 'pg', collection: 'users', actor: 'ghost', label: 'who', pin: pin(10, 10) }, + ], + }, + ], + }; + expect(codes(design(d))).toEqual(['dangling_reference', 'dangling_reference', 'unknown_actor']); + }); + + it('system_map: dotted hierarchy needs its parent, relationships need both ends', () => { + const m: DocBlock = { + type: 'system_map', + title: 'Shop', + primary: true, + elements: [ + { path: 'shop', label: 'Shop', type: 'system' }, + { path: 'shop.api', label: 'API', type: 'container', status: 'modified' }, + { path: 'shop.api.orders', label: 'Orders', type: 'component', origin: 'graph' }, + { path: 'billing.worker', label: 'Worker', type: 'container' }, + ], + relationships: [ + { from: 'shop.api', to: 'shop.api.orders', kind: 'call' }, + { from: 'shop.api', to: 'shop.db', kind: 'call' }, + ], + }; + expect(codes(design(m))).toEqual(['map_parent', 'dangling_edge']); + }); +}); + +describe('targetIssues', () => { + it('flags a document written for another change', () => { + const doc = built(); + expect(targetIssues(doc, change)).toEqual([]); + expect(targetIssues(doc, { ...change, headSha: 'd'.repeat(40) })[0].code).toBe('stale_target'); + }); +}); + +describe('pins and ids', () => { + it('round-trips a pin link', () => { + const p = pin(3, 9); + expect(parsePinLink(pinLink(p))).toEqual(p); + expect(parsePinLink('base:src/a.ts#L7')).toEqual({ side: 'base', path: 'src/a.ts', start: 7, end: 7 }); + expect(parsePinLink('https://example.com')).toBeNull(); + expect(markdownPinLinks('[a](head:x.ts#L1) [b](https://e.com) [c](base:y.ts#L2-L3)')).toHaveLength(2); + }); + + it('derives block ids from content, ignoring any id already set', () => { + const b: DocBlock = { type: 'markdown', text: 'same' }; + expect(blockId(b)).toBe(blockId({ ...b, id: 'whatever' })); + expect(blockId(b)).not.toBe(blockId({ type: 'markdown', text: 'other' })); + }); + + it('reseals the digest after an edit', () => { + const doc = built(); + const edited = sealReviewDoc({ ...doc, title: 'Edited' }); + expect(edited.digest).not.toBe(doc.digest); + expect(sealReviewDoc(edited).digest).toBe(edited.digest); + }); +}); + +describe('renderReviewDocMarkdown', () => { + it('renders sections, hunks, and a Mermaid flowchart with change colouring', () => { + const doc = docWith([...built().sections.slice(0, 1), { kind: 'design', blocks: [flow()] }, ...built().sections.slice(1)]); + expect(validateReviewDoc(doc, resolve)).toEqual([]); + const md = renderReviewDocMarkdown(doc); + expect(md).toContain('### What and why'); + expect(md).toContain('### Design'); + expect(md).toContain('```mermaid\nflowchart LR'); + expect(md).toContain('n_valid{"Valid?"}'); + expect(md).toContain('n_valid -->|"yes"| n_save'); + expect(md).toContain('class n_save added'); + expect(md).toContain('`L10–13`'); + expect(md).not.toContain('head:src/'); + }); + + it('escapes quotes in Mermaid labels', () => { + const doc = docWith([{ kind: 'design', blocks: [flow({ nodes: [{ key: 'q', label: 'say "hi"', kind: 'terminal' }], edges: [] })] }]); + expect(renderReviewDocMarkdown(doc)).toContain('n_q(["say #quot;hi#quot;"])'); + }); +}); diff --git a/src/review/doc.ts b/src/review/doc.ts new file mode 100644 index 0000000..7a29d54 --- /dev/null +++ b/src/review/doc.ts @@ -0,0 +1,1287 @@ +/** + * Review document — `vg.review.doc.v1`. + * + * A structured, reader-ordered explanation of one change: what and why, + * requirements, design (exactly one primary diagram), implementation. It is + * built from typed blocks — prose, code peeks, flow diagrams, sequence + * diagrams, call-stack diffs, data-store views and system maps — so the same + * document renders as Markdown on a pull request, in a terminal, or on a + * canvas, and an agent can patch one node or edge at a time by id. + * + * The evidence rule is enforced here, not left to the author: every claim + * about real code carries a pin (`side`, `path`, line range), and a pin that + * does not land on the file it names is an error, never a fuzzy match. Every + * diagram element records its origin — `graph` when vg derived it, `agent` + * when a model or a person drew it — so a reader can always tell the two apart. + * + * This module is pure: validation takes a resolver for line counts, building + * takes the change set and groups. No clock, no randomness, sorted output. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { defaultRun, type ChangeSet, type GitRunner } from './git.js'; +import type { DiffGroups } from './groups.js'; +import { headIsWorkingTree, READ_KINDS } from './groups.js'; +import { digest, type AnalysisCapsule, type ReviewFinding, type ReviewFindings } from './schemas.js'; + +export const DOC_SCHEMA = 'vg.review.doc.v1' as const; + +// ─── Shapes ───────────────────────────────────────────────────────────────── + +export type PinSide = 'base' | 'head'; + +/** A repo-relative, 1-based, inclusive line range on one side of the change. */ +export interface Pin { + side: PinSide; + path: string; + start: number; + end: number; + /** In a multi-repo document, the `repos[].id` the path belongs to; omitted means the document's own repository. */ + repo?: string; +} + +/** Another repository a multi-repo document covers (`vg review doc --also`). */ +export interface DocRepo { + /** Short id pins name (`repo`) and links use (`head@:path#L1`). */ + id: string; + /** `owner/repo` when the remote names one. */ + name: string | null; + repo_key: string; + base_sha: string; + head_sha: string; + merge_base: string | null; + dirty_tree_hash: string | null; +} + +/** A repos[].id: lowercase, short, safe in a link. */ +export const REPO_ID = /^[a-z0-9][a-z0-9_.-]{0,39}$/; + +/** Who produced a diagram element. */ +export type Origin = 'graph' | 'agent'; + +export type ChangeStatus = 'added' | 'removed' | 'modified' | 'unchanged'; + +export interface MarkdownBlock { + type: 'markdown'; + id?: string; + /** Who wrote the text: absent or `graph` when vg did, `agent` when a model or a person did. */ + origin?: Origin; + /** Inline evidence links use `[label](head:path#L10-L24)` or `base:`. */ + text: string; +} + +export interface CalloutBlock { + type: 'callout'; + id?: string; + origin?: Origin; + tone: 'note' | 'warning' | 'risk'; + text: string; + pins?: Pin[]; +} + +export interface CodePeekBlock { + type: 'code_peek'; + id?: string; + origin?: Origin; + pin: Pin; + caption?: string; + primary?: boolean; +} + +export interface DividerBlock { + type: 'divider'; + id?: string; +} + +export interface FlowNode { + key: string; + label: string; + description?: string; + kind?: 'process' | 'decision' | 'terminal'; + /** Code that backs the node. Omit only for start/exit nodes no code backs. */ + pins?: Pin[]; + status?: ChangeStatus; + origin?: Origin; + /** Swimlane: an actor, package, or policy column. */ + lane?: string; +} + +export interface FlowEdge { + from: string; + to: string; + label?: string; + /** `branch_true` / `branch_false` leave a decision; `error` and `async` render dashed. */ + kind?: 'call' | 'callback' | 'async' | 'error' | 'branch_true' | 'branch_false' | 'loop' | 'return'; + status?: ChangeStatus; + origin?: Origin; +} + +export interface FlowBlock { + type: 'flow'; + id?: string; + title: string; + description?: string; + direction?: 'right' | 'down'; + primary?: boolean; + nodes: FlowNode[]; + edges: FlowEdge[]; +} + +export interface SequenceStep { + from: string; + to: string; + label: string; + style?: 'call' | 'return' | 'async'; + pins?: Pin[]; + /** An explanation, for a step no single span of code shows. */ + note?: string; + origin?: Origin; +} + +export interface SequenceBlock { + type: 'sequence'; + id?: string; + title: string; + primary?: boolean; + actors: { key: string; label: string }[]; + steps: SequenceStep[]; +} + +export interface StackFrame { + /** Aligns a frame across base and head, so a moved frame still compares. */ + key?: string; + parent_key?: string; + label?: string; + pin: Pin; + call_site?: Pin; + /** Supporting code (initializers, constants) that shares the frame but adds no edge. */ + context?: Pin[]; + via?: { kind: 'call' | 'async' | 'queue' | 'callback' | 'rpc'; reason?: string }; + /** Whether the frame is new, gone, edited, or untouched by this change. */ + status?: ChangeStatus; + origin?: Origin; +} + +export interface CallStackDiffBlock { + type: 'call_stack_diff'; + id?: string; + title: string; + primary?: boolean; + /** + * `computed`: `base` is the real before-side path. `not_computed`: nobody + * built the base side, so an empty `base` means nothing. `absent`: the + * target did not exist at the base commit. Omitted means `computed`. + */ + base_status?: 'computed' | 'not_computed' | 'absent'; + base: StackFrame[]; + head: StackFrame[]; +} + +export interface StoreField { + key: string; + label: string; + data_type: string; + nullable?: boolean; + primary_key?: boolean; + references?: { store: string; collection: string; field: string }; + /** Nested fields, for document stores. */ + fields?: StoreField[]; + /** Where the field is declared (a schema file, an entity class). */ + pin?: Pin; +} + +export interface DataStoreBlock { + type: 'data_store'; + id?: string; + /** Data-store parts carry no per-element origin; the block's says who drew it. */ + origin?: Origin; + title: string; + primary?: boolean; + actors: { key: string; label: string; map_path?: string }[]; + stores: { + key: string; + label: string; + storage: 'relational' | 'document'; + kind?: 'database' | 'object_store' | 'bucket' | 'artifact_store' | 'file_store'; + map_path?: string; + /** `pin`: where the collection (table, entity, model) is declared. */ + collections: { key: string; label: string; fields: StoreField[]; pin?: Pin }[]; + }[]; + use_cases: { + label: string; + summary?: string; + operations: { + kind: 'read' | 'write'; + store: string; + collection: string; + /** Dotted for nested document fields: `address.city`. */ + field?: string; + actor: string; + label: string; + pin: Pin; + }[]; + }[]; +} + +export type MapElementType = 'person' | 'system' | 'container' | 'data_store' | 'component' | 'code'; + +export interface SystemMapBlock { + type: 'system_map'; + id?: string; + title: string; + primary?: boolean; + /** Dotted paths express the hierarchy: `shop.api.orders`. */ + elements: { path: string; label: string; type: MapElementType; status?: ChangeStatus; pins?: Pin[]; origin?: Origin }[]; + relationships: { + from: string; + to: string; + kind: 'call' | 'semantic'; + label?: string; + pins?: Pin[]; + status?: ChangeStatus; + origin?: Origin; + }[]; +} + +export type DocBlock = + | MarkdownBlock + | CalloutBlock + | CodePeekBlock + | DividerBlock + | FlowBlock + | SequenceBlock + | CallStackDiffBlock + | DataStoreBlock + | SystemMapBlock; + +export type SectionKind = 'what_why' | 'requirements' | 'design' | 'implementation'; + +/** Reading order. A section that does not earn its place is left out. */ +export const SECTION_ORDER: readonly SectionKind[] = ['what_why', 'requirements', 'design', 'implementation']; + +export const SECTION_TITLE: Record = { + what_why: 'What and why', + requirements: 'Requirements', + design: 'Design', + implementation: 'Implementation', +}; + +export interface DocSection { + kind: SectionKind; + title?: string; + blocks: DocBlock[]; +} + +export interface ReviewDoc { + schema_version: typeof DOC_SCHEMA; + /** + * `explain`: the document explains code as it is (`vg show + * --diagram`), so nothing in it is a change and the call path has no before + * side. Omitted means `change`. + */ + kind?: 'change' | 'explain'; + title: string; + target: { + repo_key: string | null; + base_sha: string; + head_sha: string; + merge_base: string | null; + dirty_tree_hash: string | null; + }; + sections: DocSection[]; + /** The other repositories a multi-repo document covers; pins name them with `repo`. */ + repos?: DocRepo[]; + /** The diff groups this document was written against (`grp:*` ids). */ + groups_digest: string | null; + generator: { by: 'vg' | 'agent' | 'mixed'; notes: string[] }; + /** + * Set when the document was saved (doc-store.ts): which saved document and + * version this is, and who wrote that version. Covered by the digest. + */ + revision?: { doc_id: string; version: number; by: 'vg' | 'agent'; at: string }; + /** `sha256:` over every field above. */ + digest?: string; +} + +// ─── Limits ───────────────────────────────────────────────────────────────── + +export const LIMITS = { + flowNodes: 100, + flowEdges: 300, + sequenceSteps: 200, + frames: 200, + framePins: 1000, + mapElements: 500, + mapRelationships: 1500, + blocks: 400, + text: 20_000, + repos: 8, +} as const; + +const DIAGRAM_TYPES = new Set(['flow', 'sequence', 'call_stack_diff', 'data_store', 'system_map', 'code_peek']); + +// ─── Pin resolution ───────────────────────────────────────────────────────── + +/** Line count of `path` on `side`, or null when the file does not exist there. */ +export type PinResolver = (side: PinSide, path: string, repo?: string) => number | null; + +function lineCount(text: string): number { + if (text === '') return 0; + const n = text.split('\n').length; + return text.endsWith('\n') ? n - 1 : n; +} + +const MAX_PIN_FILE_BYTES = 4 * 1024 * 1024; + +/** + * Resolve pins against the change: `base` reads the base commit, `head` reads + * the working tree when the review is in place and the head commit otherwise + * — the same two sides `vg review` compared. + */ +export function makePinResolver( + change: ChangeSet, + opts: { base?: string; inPlace?: boolean }, + run: GitRunner = defaultRun, +): PinResolver { + const cache = new Map(); + const fromTree = headIsWorkingTree(opts); + return (side, rel, repo) => { + // Another repository's pin cannot land in this checkout; a multi-repo check pairs each with its own. + if (repo) return null; + const key = `${side}\0${rel}`; + if (cache.has(key)) return cache.get(key)!; + let value: number | null = null; + if (side === 'head' && fromTree) { + try { + const abs = path.join(change.topLevel, rel); + const stat = fs.statSync(abs); + if (stat.isFile() && stat.size <= MAX_PIN_FILE_BYTES) value = lineCount(fs.readFileSync(abs, 'utf8')); + } catch { + value = null; + } + } else { + const sha = side === 'base' ? change.baseSha : change.headSha; + if (sha) { + const res = run(['show', `${sha}:${rel}`], change.topLevel); + if (res.status === 0 && res.stdout.length <= MAX_PIN_FILE_BYTES) value = lineCount(res.stdout); + } + } + cache.set(key, value); + return value; + }; +} + +/** + * `head:src/a.ts#L10-L24` → a pin. Single line `#L7` is a one-line range. + * In a multi-repo document `head@client:src/a.ts#L3` names another repository. + */ +export function parsePinLink(href: string): Pin | null { + const m = href.match(/^(base|head)(?:@([a-z0-9][a-z0-9_.-]{0,39}))?:([^#\s]+)#L(\d+)(?:-L?(\d+))?$/); + if (!m) return null; + const start = Number(m[4]); + return { side: m[1] as PinSide, path: m[3], start, end: m[5] === undefined ? start : Number(m[5]), ...(m[2] ? { repo: m[2] } : {}) }; +} + +export function pinLink(pin: Pin): string { + return `${pin.side}${pin.repo ? `@${pin.repo}` : ''}:${pin.path}#L${pin.start}${pin.end !== pin.start ? `-L${pin.end}` : ''}`; +} + +/** Every `[label](base:…|head:…)` link in a Markdown string, in order. */ +export function markdownPinLinks(text: string): { href: string; pin: Pin | null }[] { + const out: { href: string; pin: Pin | null }[] = []; + const re = /\]\(((?:base|head)(?:@[a-z0-9][a-z0-9_.-]*)?:[^)\s]*)\)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) out.push({ href: m[1], pin: parsePinLink(m[1]) }); + return out; +} + +// ─── Validation ───────────────────────────────────────────────────────────── + +export interface DocIssue { + /** JSON path into the document, e.g. `$.sections[2].blocks[0].nodes[3]`. */ + path: string; + code: string; + message: string; +} + +type Obj = Record; + +function isObj(v: unknown): v is Obj { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +function nonEmpty(v: unknown): v is string { + return typeof v === 'string' && v.trim().length > 0; +} + +class Checker { + readonly issues: DocIssue[] = []; + private readonly blockIds = new Set(); + + constructor( + private readonly resolve: PinResolver | null, + /** The ids a pin may name with `repo` (a multi-repo document's `repos`). */ + private readonly repoIds: ReadonlySet = new Set(), + ) {} + + add(at: string, code: string, message: string): void { + this.issues.push({ path: at, code, message }); + } + + str(o: Obj, key: string, at: string, opts: { required?: boolean; max?: number } = {}): void { + const v = o[key]; + if (v === undefined) { + if (opts.required) this.add(`${at}.${key}`, 'missing', `${key} is required`); + return; + } + if (typeof v !== 'string' || (opts.required && !v.trim())) { + this.add(`${at}.${key}`, 'type', `${key} must be a non-empty string`); + return; + } + if (v.length > (opts.max ?? LIMITS.text)) this.add(`${at}.${key}`, 'too_long', `${key} exceeds ${opts.max ?? LIMITS.text} characters`); + } + + oneOf(o: Obj, key: string, allowed: readonly string[], at: string, required = false): void { + const v = o[key]; + if (v === undefined) { + if (required) this.add(`${at}.${key}`, 'missing', `${key} is required`); + return; + } + if (typeof v !== 'string' || !allowed.includes(v)) { + this.add(`${at}.${key}`, 'enum', `${key} must be one of ${allowed.join(', ')}`); + } + } + + arr(o: Obj, key: string, at: string, opts: { min?: number; max?: number; required?: boolean } = {}): unknown[] { + const v = o[key]; + if (v === undefined) { + if (opts.required || (opts.min ?? 0) > 0) this.add(`${at}.${key}`, 'missing', `${key} is required`); + return []; + } + if (!Array.isArray(v)) { + this.add(`${at}.${key}`, 'type', `${key} must be an array`); + return []; + } + if (opts.min !== undefined && v.length < opts.min) this.add(`${at}.${key}`, 'too_few', `${key} needs at least ${opts.min}`); + if (opts.max !== undefined && v.length > opts.max) this.add(`${at}.${key}`, 'too_many', `${key} allows at most ${opts.max}`); + return v; + } + + /** A pin must be well-formed, repo-relative, and land on the file it names. */ + pin(v: unknown, at: string): void { + if (!isObj(v)) { + this.add(at, 'pin', 'pin must be an object { side, path, start, end }'); + return; + } + const { side, path: p, start, end } = v; + if (side !== 'base' && side !== 'head') { + this.add(`${at}.side`, 'pin', 'side must be base or head'); + return; + } + if (typeof p !== 'string' || !p || p.startsWith('/') || /^[a-z]:/i.test(p) || p.split(/[\\/]/).includes('..')) { + this.add(`${at}.path`, 'pin_path', 'path must be repository-relative'); + return; + } + if (!Number.isInteger(start) || !Number.isInteger(end) || (start as number) < 1 || (end as number) < (start as number)) { + this.add(at, 'pin_range', 'start and end must be integers with 1 <= start <= end'); + return; + } + const repo = v.repo; + if (repo !== undefined && (typeof repo !== 'string' || !this.repoIds.has(repo))) { + this.add(`${at}.repo`, 'pin_repo', `repo must name one of the document's repos (${[...this.repoIds].join(', ') || 'none'})`); + return; + } + if (!this.resolve) return; + const lines = this.resolve(side, p, repo as string | undefined); + const where = repo ? `${p} in ${String(repo)}` : p; + if (lines === null) { + this.add(at, 'pin_missing_file', `${where} does not exist on the ${side} side`); + } else if ((end as number) > lines) { + this.add(at, 'pin_out_of_range', `${where} has ${lines} line${lines === 1 ? '' : 's'} on the ${side} side; the pin ends at line ${String(end)}`); + } + } + + pins(o: Obj, key: string, at: string, opts: { min?: number; max?: number } = {}): void { + this.arr(o, key, at, { max: opts.max ?? LIMITS.framePins, min: opts.min }).forEach((p, i) => this.pin(p, `${at}.${key}[${i}]`)); + } + + markdown(text: unknown, at: string): void { + if (typeof text !== 'string') return; + for (const link of markdownPinLinks(text)) { + if (!link.pin) this.add(at, 'pin_link', `malformed evidence link ${link.href} — use head:path#L10-L24`); + else this.pin(link.pin, `${at}<${link.href}>`); + } + } + + origin(o: Obj, at: string): void { + this.oneOf(o, 'origin', ['graph', 'agent'], at); + } + + status(o: Obj, at: string): void { + this.oneOf(o, 'status', ['added', 'removed', 'modified', 'unchanged'], at); + } + + uniqueKeys(items: unknown[], key: string, at: string, label: string): Set { + const keys = new Set(); + items.forEach((item, i) => { + if (!isObj(item)) { + this.add(`${at}[${i}]`, 'type', `${label} must be an object`); + return; + } + const k = item[key]; + if (!nonEmpty(k)) { + this.add(`${at}[${i}].${key}`, 'missing', `${label} ${key} is required`); + return; + } + if (keys.has(k)) this.add(`${at}[${i}].${key}`, 'duplicate_key', `duplicate ${label} ${key} ${k}`); + keys.add(k); + }); + return keys; + } + + block(b: unknown, at: string): boolean { + if (!isObj(b)) { + this.add(at, 'type', 'block must be an object'); + return false; + } + if (b.id !== undefined) { + if (!nonEmpty(b.id)) this.add(`${at}.id`, 'type', 'id must be a non-empty string'); + else if (this.blockIds.has(b.id)) this.add(`${at}.id`, 'duplicate_id', `duplicate block id ${b.id}`); + else this.blockIds.add(b.id); + } + if (b.primary !== undefined && typeof b.primary !== 'boolean') this.add(`${at}.primary`, 'type', 'primary must be a boolean'); + if (b.primary === true && !DIAGRAM_TYPES.has(String(b.type))) { + this.add(`${at}.primary`, 'primary_type', `a ${String(b.type)} block cannot be the primary diagram`); + } + switch (b.type) { + case 'markdown': + this.str(b, 'text', at, { required: true }); + this.markdown(b.text, `${at}.text`); + this.origin(b, at); + break; + case 'callout': + this.origin(b, at); + this.oneOf(b, 'tone', ['note', 'warning', 'risk'], at, true); + this.str(b, 'text', at, { required: true }); + this.markdown(b.text, `${at}.text`); + this.pins(b, 'pins', at); + break; + case 'code_peek': + this.origin(b, at); + this.pin(b.pin, `${at}.pin`); + this.str(b, 'caption', at); + break; + case 'divider': + break; + case 'flow': + this.flow(b, at); + break; + case 'sequence': + this.sequence(b, at); + break; + case 'call_stack_diff': + this.callStack(b, at); + break; + case 'data_store': + this.origin(b, at); + this.dataStore(b, at); + break; + case 'system_map': + this.systemMap(b, at); + break; + default: + this.add(`${at}.type`, 'unknown_block', `unknown block type ${String(b.type)}`); + } + return b.primary === true; + } + + flow(b: Obj, at: string): void { + this.str(b, 'title', at, { required: true }); + this.str(b, 'description', at); + this.oneOf(b, 'direction', ['right', 'down'], at); + const nodes = this.arr(b, 'nodes', at, { min: 1, max: LIMITS.flowNodes }); + const keys = this.uniqueKeys(nodes, 'key', `${at}.nodes`, 'node'); + const decisions = new Set(); + nodes.forEach((n, i) => { + if (!isObj(n)) return; + const nat = `${at}.nodes[${i}]`; + this.str(n, 'label', nat, { required: true }); + this.str(n, 'description', nat); + this.oneOf(n, 'kind', ['process', 'decision', 'terminal'], nat); + this.status(n, nat); + this.origin(n, nat); + this.str(n, 'lane', nat); + this.pins(n, 'pins', nat); + if (n.kind === 'decision' && typeof n.key === 'string') decisions.add(n.key); + // The evidence rule: a process node describes code, so it must point at it. + if ((n.kind === undefined || n.kind === 'process') && (!Array.isArray(n.pins) || n.pins.length === 0)) { + this.add(nat, 'unpinned_node', `process node ${String(n.key)} has no pins — link the code it stands for, or mark it terminal`); + } + }); + const edges = this.arr(b, 'edges', at, { max: LIMITS.flowEdges }); + edges.forEach((e, i) => { + const eat = `${at}.edges[${i}]`; + if (!isObj(e)) { + this.add(eat, 'type', 'edge must be an object'); + return; + } + for (const end of ['from', 'to'] as const) { + if (!nonEmpty(e[end])) this.add(`${eat}.${end}`, 'missing', `${end} is required`); + else if (!keys.has(e[end])) this.add(`${eat}.${end}`, 'dangling_edge', `no node with key ${e[end]}`); + } + this.str(e, 'label', eat); + this.oneOf(e, 'kind', ['call', 'callback', 'async', 'error', 'branch_true', 'branch_false', 'loop', 'return'], eat); + this.status(e, eat); + this.origin(e, eat); + if ((e.kind === 'branch_true' || e.kind === 'branch_false') && typeof e.from === 'string' && keys.has(e.from) && !decisions.has(e.from)) { + this.add(`${eat}.kind`, 'branch_from_non_decision', `${e.kind} must leave a decision node`); + } + }); + } + + sequence(b: Obj, at: string): void { + this.str(b, 'title', at, { required: true }); + const actors = this.arr(b, 'actors', at, { min: 2 }); + const keys = this.uniqueKeys(actors, 'key', `${at}.actors`, 'actor'); + actors.forEach((a, i) => isObj(a) && this.str(a, 'label', `${at}.actors[${i}]`, { required: true })); + this.arr(b, 'steps', at, { min: 1, max: LIMITS.sequenceSteps }).forEach((s, i) => { + const sat = `${at}.steps[${i}]`; + if (!isObj(s)) { + this.add(sat, 'type', 'step must be an object'); + return; + } + for (const end of ['from', 'to'] as const) { + if (!nonEmpty(s[end])) this.add(`${sat}.${end}`, 'missing', `${end} is required`); + else if (!keys.has(s[end])) this.add(`${sat}.${end}`, 'unknown_actor', `no actor with key ${s[end]}`); + } + this.str(s, 'label', sat, { required: true }); + this.oneOf(s, 'style', ['call', 'return', 'async'], sat); + this.str(s, 'note', sat); + this.origin(s, sat); + this.pins(s, 'pins', sat); + const pinned = Array.isArray(s.pins) && s.pins.length > 0; + if (!pinned && !nonEmpty(s.note)) this.add(sat, 'unpinned_step', 'a step needs pins, or a note explaining why no code shows it'); + }); + } + + frames(frames: unknown[], at: string): void { + const seen = new Set(); + frames.forEach((f, i) => { + const fat = `${at}[${i}]`; + if (!isObj(f)) { + this.add(fat, 'type', 'frame must be an object'); + return; + } + this.pin(f.pin, `${fat}.pin`); + if (f.call_site !== undefined) this.pin(f.call_site, `${fat}.call_site`); + this.pins(f, 'context', fat, { max: LIMITS.framePins }); + this.str(f, 'label', fat); + this.origin(f, fat); + this.status(f, fat); + if (f.via !== undefined) { + if (!isObj(f.via)) this.add(`${fat}.via`, 'type', 'via must be an object'); + else { + this.oneOf(f.via, 'kind', ['call', 'async', 'queue', 'callback', 'rpc'], `${fat}.via`, true); + this.str(f.via, 'reason', `${fat}.via`); + } + } + if (f.parent_key !== undefined) { + if (!nonEmpty(f.parent_key) || !seen.has(f.parent_key)) { + this.add(`${fat}.parent_key`, 'parent_order', 'parent_key must name an earlier frame on the same side'); + } + } + if (f.key !== undefined) { + if (!nonEmpty(f.key)) this.add(`${fat}.key`, 'type', 'key must be a non-empty string'); + else if (seen.has(f.key)) this.add(`${fat}.key`, 'duplicate_key', `duplicate frame key ${f.key}`); + else seen.add(f.key); + } + }); + } + + callStack(b: Obj, at: string): void { + this.str(b, 'title', at, { required: true }); + const base = this.arr(b, 'base', at, { max: LIMITS.frames, required: true }); + const head = this.arr(b, 'head', at, { max: LIMITS.frames, required: true }); + if (base.length + head.length === 0) this.add(at, 'too_few', 'a call-stack diff needs at least one frame'); + this.oneOf(b, 'base_status', ['computed', 'not_computed', 'absent'], at); + if ((b.base_status === 'not_computed' || b.base_status === 'absent') && base.length > 0) { + this.add(`${at}.base`, 'base_status', `base must be empty when base_status is ${b.base_status}`); + } + this.frames(base, `${at}.base`); + this.frames(head, `${at}.head`); + } + + fields(fields: unknown[], at: string, prefix: string, out: Set): void { + this.uniqueKeys(fields, 'key', at, 'field'); + fields.forEach((f, i) => { + if (!isObj(f) || !nonEmpty(f.key)) return; + const fat = `${at}[${i}]`; + this.str(f, 'label', fat, { required: true }); + this.str(f, 'data_type', fat, { required: true }); + if (f.pin !== undefined) this.pin(f.pin, `${fat}.pin`); + const dotted = prefix ? `${prefix}.${f.key}` : f.key; + out.add(dotted); + if (f.fields !== undefined) this.fields(this.arr(f, 'fields', fat), `${fat}.fields`, dotted, out); + }); + } + + dataStore(b: Obj, at: string): void { + this.str(b, 'title', at, { required: true }); + const actors = this.arr(b, 'actors', at, { min: 1 }); + const actorKeys = this.uniqueKeys(actors, 'key', `${at}.actors`, 'actor'); + const stores = this.arr(b, 'stores', at, { min: 1 }); + this.uniqueKeys(stores, 'key', `${at}.stores`, 'store'); + // store → collection → dotted field set, for resolving operations and references. + const schema = new Map>>(); + const refs: { at: string; ref: Obj }[] = []; + stores.forEach((s, i) => { + if (!isObj(s) || !nonEmpty(s.key)) return; + const sat = `${at}.stores[${i}]`; + this.str(s, 'label', sat, { required: true }); + this.oneOf(s, 'storage', ['relational', 'document'], sat, true); + this.oneOf(s, 'kind', ['database', 'object_store', 'bucket', 'artifact_store', 'file_store'], sat); + const collections = this.arr(s, 'collections', sat, { min: 1 }); + this.uniqueKeys(collections, 'key', `${sat}.collections`, 'collection'); + const colMap = new Map>(); + collections.forEach((c, j) => { + if (!isObj(c) || !nonEmpty(c.key)) return; + const cat = `${sat}.collections[${j}]`; + this.str(c, 'label', cat, { required: true }); + if (c.pin !== undefined) this.pin(c.pin, `${cat}.pin`); + const fieldSet = new Set(); + const fields = this.arr(c, 'fields', cat); + this.fields(fields, `${cat}.fields`, '', fieldSet); + const walk = (list: unknown[], fat: string): void => { + list.forEach((f, k) => { + if (!isObj(f)) return; + if (f.references !== undefined) { + if (isObj(f.references)) refs.push({ at: `${fat}[${k}].references`, ref: f.references }); + else this.add(`${fat}[${k}].references`, 'type', 'references must be an object'); + } + if (Array.isArray(f.fields)) walk(f.fields, `${fat}[${k}].fields`); + }); + }; + walk(fields, `${cat}.fields`); + colMap.set(c.key, fieldSet); + }); + schema.set(s.key, colMap); + }); + const resolves = (store: unknown, collection: unknown, field: unknown): string | null => { + const cols = typeof store === 'string' ? schema.get(store) : undefined; + if (!cols) return `no store ${String(store)}`; + const fieldSet = typeof collection === 'string' ? cols.get(collection) : undefined; + if (!fieldSet) return `no collection ${String(collection)} in ${String(store)}`; + if (field !== undefined && (typeof field !== 'string' || !fieldSet.has(field))) { + return `no field ${String(field)} in ${String(store)}.${String(collection)}`; + } + return null; + }; + for (const r of refs) { + const err = resolves(r.ref.store, r.ref.collection, r.ref.field); + if (err) this.add(r.at, 'dangling_reference', err); + } + this.arr(b, 'use_cases', at, { min: 1 }).forEach((u, i) => { + const uat = `${at}.use_cases[${i}]`; + if (!isObj(u)) { + this.add(uat, 'type', 'use case must be an object'); + return; + } + this.str(u, 'label', uat, { required: true }); + this.str(u, 'summary', uat); + this.arr(u, 'operations', uat, { min: 1 }).forEach((o, j) => { + const oat = `${uat}.operations[${j}]`; + if (!isObj(o)) { + this.add(oat, 'type', 'operation must be an object'); + return; + } + this.oneOf(o, 'kind', ['read', 'write'], oat, true); + this.str(o, 'label', oat, { required: true }); + if (!nonEmpty(o.actor) || !actorKeys.has(o.actor)) this.add(`${oat}.actor`, 'unknown_actor', `no actor with key ${String(o.actor)}`); + const err = resolves(o.store, o.collection, o.field); + if (err) this.add(oat, 'dangling_reference', err); + this.pin(o.pin, `${oat}.pin`); + }); + }); + } + + systemMap(b: Obj, at: string): void { + this.str(b, 'title', at, { required: true }); + const elements = this.arr(b, 'elements', at, { min: 1, max: LIMITS.mapElements }); + const paths = this.uniqueKeys(elements, 'path', `${at}.elements`, 'element'); + elements.forEach((e, i) => { + if (!isObj(e) || !nonEmpty(e.path)) return; + const eat = `${at}.elements[${i}]`; + this.str(e, 'label', eat, { required: true }); + this.oneOf(e, 'type', ['person', 'system', 'container', 'data_store', 'component', 'code'], eat, true); + this.status(e, eat); + this.origin(e, eat); + this.pins(e, 'pins', eat); + if (!/^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/.test(e.path)) { + this.add(`${eat}.path`, 'map_path', 'path must be dot-separated segments of letters, digits, _ or -'); + } else if (e.path.includes('.')) { + const parent = e.path.slice(0, e.path.lastIndexOf('.')); + if (!paths.has(parent)) this.add(`${eat}.path`, 'map_parent', `parent ${parent} is not an element`); + } + }); + this.arr(b, 'relationships', at, { max: LIMITS.mapRelationships }).forEach((r, i) => { + const rat = `${at}.relationships[${i}]`; + if (!isObj(r)) { + this.add(rat, 'type', 'relationship must be an object'); + return; + } + for (const end of ['from', 'to'] as const) { + if (!nonEmpty(r[end]) || !paths.has(r[end])) this.add(`${rat}.${end}`, 'dangling_edge', `no element at ${String(r[end])}`); + } + this.oneOf(r, 'kind', ['call', 'semantic'], rat, true); + this.str(r, 'label', rat); + this.status(r, rat); + this.origin(r, rat); + this.pins(r, 'pins', rat); + }); + } +} + +/** + * Validate a review document. Structural checks always run; pin checks run + * when a resolver is given. Issues come back in document order, so the same + * document always yields the same list. + */ +export function validateReviewDoc(value: unknown, resolve: PinResolver | null = null): DocIssue[] { + if (!isObj(value)) return [{ path: '$', code: 'not_object', message: 'review document must be a JSON object' }]; + const repoIds = new Set(); + const repoIssues: DocIssue[] = []; + if (value.repos !== undefined) { + if (!Array.isArray(value.repos) || value.repos.length > LIMITS.repos) { + repoIssues.push({ path: '$.repos', code: 'type', message: `repos must be an array of at most ${LIMITS.repos}` }); + } else { + value.repos.forEach((r, i) => { + const at = `$.repos[${i}]`; + if (!isObj(r) || typeof r.id !== 'string' || !REPO_ID.test(r.id)) { + repoIssues.push({ path: `${at}.id`, code: 'repo_id', message: 'id must be lowercase letters, digits, ".", "_" or "-", at most 40' }); + } else if (repoIds.has(r.id)) { + repoIssues.push({ path: `${at}.id`, code: 'duplicate_id', message: `duplicate repo id ${r.id}` }); + } else if (!nonEmpty(r.head_sha) || typeof r.base_sha !== 'string' || typeof r.repo_key !== 'string') { + repoIssues.push({ path: at, code: 'missing', message: 'a repo needs repo_key, base_sha and head_sha' }); + } else { + repoIds.add(r.id); + } + }); + } + } + const ck = new Checker(resolve, repoIds); + for (const i of repoIssues) ck.add(i.path, i.code, i.message); + if (value.schema_version !== DOC_SCHEMA) ck.add('$.schema_version', 'schema', `expected ${DOC_SCHEMA}`); + ck.oneOf(value, 'kind', ['change', 'explain'], '$'); + ck.str(value, 'title', '$', { required: true, max: 300 }); + if (!isObj(value.target) || !nonEmpty(value.target.head_sha) || typeof value.target.base_sha !== 'string') { + ck.add('$.target', 'missing', 'target { base_sha, head_sha } is required'); + } + const sections = ck.arr(value, 'sections', '$', { min: 1 }); + let lastIndex = -1; + let blockCount = 0; + const seenKinds = new Set(); + sections.forEach((s, i) => { + const at = `$.sections[${i}]`; + if (!isObj(s)) { + ck.add(at, 'type', 'section must be an object'); + return; + } + const kind = s.kind as SectionKind; + const idx = SECTION_ORDER.indexOf(kind); + if (idx < 0) { + ck.add(`${at}.kind`, 'enum', `kind must be one of ${SECTION_ORDER.join(', ')}`); + } else if (seenKinds.has(kind)) { + ck.add(`${at}.kind`, 'duplicate_section', `${kind} appears more than once`); + } else if (idx < lastIndex) { + ck.add(`${at}.kind`, 'section_order', `${kind} must come before ${SECTION_ORDER[lastIndex]}`); + } + seenKinds.add(kind); + lastIndex = Math.max(lastIndex, idx); + ck.str(s, 'title', at, { max: 300 }); + const blocks = ck.arr(s, 'blocks', at, { min: 1 }); + blockCount += blocks.length; + let primaries = 0; + blocks.forEach((b, j) => { + if (ck.block(b, `${at}.blocks[${j}]`)) primaries += 1; + }); + if (kind === 'design' && primaries !== 1) { + ck.add(at, 'primary_diagram', `design needs exactly one primary diagram (found ${primaries})`); + } + if (kind !== 'design' && primaries > 0) { + ck.add(at, 'primary_outside_design', 'only the design section carries a primary diagram'); + } + }); + if (blockCount > LIMITS.blocks) ck.add('$.sections', 'too_many', `at most ${LIMITS.blocks} blocks per document`); + return ck.issues; +} + +/** Same document as a stale target? Pins are only meaningful against the change they were written for. */ +export function targetIssues(doc: ReviewDoc, change: ChangeSet): DocIssue[] { + const t = doc.target; + if (!t) return []; + if (t.base_sha !== change.baseSha || t.head_sha !== change.headSha) { + return [ + { + path: '$.target', + code: 'stale_target', + message: `written for ${t.base_sha.slice(0, 7)}..${t.head_sha.slice(0, 7)}; the change is ${change.baseSha.slice(0, 7)}..${change.headSha.slice(0, 7)} — repin before reading`, + }, + ]; + } + return []; +} + +// ─── Building (deterministic first pass) ──────────────────────────────────── + +/** Content-derived block id: identical content always gets the identical id. */ +export function blockId(block: DocBlock): string { + const { id: _id, ...rest } = block as DocBlock & { id?: string }; + return `blk_${digest(rest).slice('sha256:'.length, 'sha256:'.length + 12)}`; +} + +export function withIds(blocks: DocBlock[]): DocBlock[] { + const seen = new Map(); + return blocks.map((b) => { + const base = blockId(b); + const n = seen.get(base) ?? 0; + seen.set(base, n + 1); + return { ...b, id: n === 0 ? base : `${base}_${n}` }; + }); +} + +export const MAX_HUNK_LINKS = 6; +const MAX_PEELED_LISTED = 25; + +export function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? '' : 's'}`; +} + +export function mdEscape(text: string): string { + return text.replace(/([\\[\]`*_])/g, '\\$1'); +} + +export interface BuildDocInput { + change: ChangeSet; + groups: DiffGroups; + repoKey: string | null; + resolve: PinResolver; + /** Deterministic findings, when a code map was available. */ + findings?: ReviewFindings | null; + capsule?: AnalysisCapsule | null; + /** Diagrams derived from the code map (derive.ts): the primary first, plus implementation callouts. */ + design?: { blocks: DocBlock[]; notes: string[]; implementation?: DocBlock[]; fold?: Map } | null; + /** + * The VG Code session the change came from (session.ts): a title, the why + * and the agent's own account for what_why, and each request as a pinned + * requirement. Its presence marks the document `generator.by: "mixed"`, + * because it quotes the person's requests and the agent's words. + */ + session?: { title: string; what: DocBlock[]; requirements: DocBlock[]; notes: string[] } | null; +} + +function findingPins(finding: ReviewFinding, capsule: AnalysisCapsule | null | undefined, resolve: PinResolver): { pins: Pin[]; dropped: number } { + if (!capsule) return { pins: [], dropped: 0 }; + const byId = new Map(capsule.evidence.map((e) => [e.id, e])); + const pins: Pin[] = []; + let dropped = 0; + const seen = new Set(); + for (const id of finding.evidence_ids) { + const ev = byId.get(id); + if (!ev?.path || !ev.start_line) continue; + const pin: Pin = { side: 'head', path: ev.path, start: ev.start_line, end: Math.max(ev.start_line, ev.end_line ?? ev.start_line) }; + const key = pinLink(pin); + if (seen.has(key)) continue; + seen.add(key); + const lines = resolve('head', pin.path); + // The evidence rule cuts both ways: vg never emits a pin it cannot land. + if (lines === null || pin.end > lines) { + dropped += 1; + continue; + } + pins.push(pin); + } + return { pins, dropped }; +} + +/** + * The deterministic first pass of a review document: what changed, grouped + * and linked to every hunk, plus the deterministic findings with their + * evidence pinned. It writes no "why" it cannot know and no design diagram it + * has not derived — those sections are left for the graph-derived diagrams and + * for the author, and the generator notes say so. + */ +export function buildReviewDoc(input: BuildDocInput): ReviewDoc { + const { change, groups, resolve } = input; + const notes: string[] = []; + const fileByPath = new Map(change.files.map((f) => [f.path.replace(/\\/g, '/'), f])); + const c = groups.counts; + const totalAdded = groups.groups.reduce((n, g) => n + g.added_lines, 0); + const totalRemoved = groups.groups.reduce((n, g) => n + g.removed_lines, 0); + + const impl = groups.groups.filter((g) => READ_KINDS.has(g.kind)); + const peeled = groups.groups.filter((g) => !READ_KINDS.has(g.kind)); + if (groups.note) notes.push(groups.note); + + const summary: string[] = []; + if (c.files === 0) { + summary.push('No reviewable changes.'); + } else { + summary.push( + `${plural(c.files, 'file')} changed (+${totalAdded} −${totalRemoved}) in ${plural(c.groups, 'group')}: ${plural(c.implementation_files, 'implementation file')}, ${c.peeled_files} peeled off.`, + ); + if (impl.length > 0) { + summary.push(''); + summary.push('Implementation, in reading order:'); + for (const g of impl) summary.push(`- ${mdEscape(g.label)} — ${plural(g.files.length, 'file')} (+${g.added_lines} −${g.removed_lines})`); + } + if (peeled.length > 0) { + summary.push(''); + summary.push(`Peeled off: ${peeled.map((g) => `${g.label.toLowerCase()} (${g.files.length})`).join(', ')}.`); + } + } + const whatWhy: DocBlock[] = [...(input.session?.what ?? []), { type: 'markdown', text: summary.join('\n') }]; + if (input.session) notes.push(...input.session.notes); + else notes.push('what_why states what changed; the why is left to the author or the session that made the change'); + + const implBlocks: DocBlock[] = []; + const findings = input.findings + ? [...input.findings.security_findings, ...input.findings.architecture_findings] + : []; + let droppedPins = 0; + for (const f of findings) { + const { pins, dropped } = findingPins(f, input.capsule, resolve); + droppedPins += dropped; + const tone = f.severity === 'high' || f.severity === 'critical' || f.protected_finding ? 'risk' : 'warning'; + implBlocks.push({ + type: 'callout', + tone, + text: `**${f.severity}** · ${mdEscape(f.claim)}${f.remediation ? `\n\n${mdEscape(f.remediation)}` : ''}\n\nFinding \`${f.id}\` — \`vg review explain ${f.id}\` shows the evidence.`, + ...(pins.length > 0 ? { pins } : {}), + }); + } + if (droppedPins > 0) notes.push(`${plural(droppedPins, 'finding evidence span')} did not land on the head side and ${droppedPins === 1 ? 'was' : 'were'} left out`); + if (input.findings === undefined || input.findings === null) notes.push('no code map was read, so deterministic findings are not included'); + + for (const g of impl) { + const lines: string[] = [`**${mdEscape(g.label)}** · ${plural(g.files.length, 'file')} (+${g.added_lines} −${g.removed_lines})`, '']; + for (const gf of g.files) { + const file = fileByPath.get(gf.path); + const links: string[] = []; + if (gf.op === 'removed') { + const n = resolve('base', gf.path); + if (n && n > 0) links.push(`[removed, ${plural(n, 'line')}](${pinLink({ side: 'base', path: gf.path, start: 1, end: n })})`); + } else { + const n = resolve('head', gf.path); + for (const h of (file?.hunks ?? []).slice(0, MAX_HUNK_LINKS)) { + if (n === null || h.end > n) continue; + links.push(`[L${h.start}${h.end !== h.start ? `–${h.end}` : ''}](${pinLink({ side: 'head', path: gf.path, start: h.start, end: h.end })})`); + } + const more = (file?.hunks.length ?? 0) - MAX_HUNK_LINKS; + if (more > 0) links.push(`+${more} more`); + } + lines.push(`- \`${gf.path}\` ${gf.op} +${gf.added_lines} −${gf.removed_lines}${links.length ? ` · ${links.join(' · ')}` : ''}`); + // The structural fold: the file's changed functions, signatures first, long new ones folded to their steps. + const fold = input.design?.fold?.get(gf.path); + if (fold) lines.push(...fold.split('\n').map((l) => ` ${l}`)); + } + implBlocks.push({ type: 'markdown', text: lines.join('\n') }); + } + + implBlocks.push(...(input.design?.implementation ?? [])); + + if (peeled.length > 0) { + const lines: string[] = ['**Peeled off** — not implementation; skim, do not review line by line.', '']; + for (const g of peeled) { + const listed = g.files.slice(0, MAX_PEELED_LISTED).map((f) => `\`${f.path}\``); + const more = g.files.length - listed.length; + lines.push(`- ${mdEscape(g.label)} (${g.files.length}): ${listed.join(', ')}${more > 0 ? `, +${more} more` : ''}`); + } + implBlocks.push({ type: 'markdown', text: lines.join('\n') }); + } + const design = input.design?.blocks ?? []; + if (input.design) notes.push(...input.design.notes); + if (design.length === 0) notes.push('design is left out until a diagram is derived from the code map or drawn by the author'); + + const sections: DocSection[] = [{ kind: 'what_why', blocks: withIds(whatWhy) }]; + if (input.session && input.session.requirements.length > 0) sections.push({ kind: 'requirements', blocks: withIds(input.session.requirements) }); + if (design.length > 0) sections.push({ kind: 'design', blocks: withIds(design) }); + if (implBlocks.length > 0) sections.push({ kind: 'implementation', blocks: withIds(implBlocks) }); + + const shortRange = change.baseSha === change.headSha + ? `working tree vs ${change.headSha.slice(0, 7)}` + : `${change.baseSha.slice(0, 7)}..${change.headSha.slice(0, 7)}${change.dirty ? ' + working tree' : ''}`; + const body: ReviewDoc = { + schema_version: DOC_SCHEMA, + title: input.session ? `${input.session.title} (${shortRange})` : `Review: ${shortRange}`, + target: { + repo_key: input.repoKey, + base_sha: change.baseSha, + head_sha: change.headSha, + merge_base: change.mergeBase, + dirty_tree_hash: change.dirtyTreeHash, + }, + sections, + groups_digest: groups.digest, + generator: { by: input.session ? 'mixed' : 'vg', notes }, + }; + return { ...body, digest: digest(body) }; +} + +/** Recompute the digest after an edit (the digest never covers itself). */ +export function sealReviewDoc(doc: ReviewDoc): ReviewDoc { + const { digest: _old, ...body } = doc; + return { ...body, digest: digest(body) }; +} + +// ─── Markdown rendering ───────────────────────────────────────────────────── + +function pinText(pin: Pin): string { + return `\`${pin.repo ? `${pin.repo}:` : ''}${pin.path}:${pin.start}${pin.end !== pin.start ? `-${pin.end}` : ''}\`${pin.side === 'base' ? ' (before)' : ''}`; +} + +/** Evidence links become plain `path:line` references a PR comment can show. */ +function renderLinks(text: string): string { + return text.replace(/\[([^\]]*)\]\(((?:base|head)(?:@[a-z0-9][a-z0-9_.-]*)?:[^)\s]*)\)/g, (_m, label: string, href: string) => { + const pin = parsePinLink(href); + if (!pin) return label; + // A hunk link (`L10–24`) already sits beside its file's path; repeating + // the path on every link only makes the line harder to read. + if (/^L\d/.test(label)) return `\`${label}\``; + return `${label} (${pinText(pin)})`; + }); +} + +function mermaidLabel(text: string): string { + return `"${text.replace(/"/g, '#quot;').replace(/\n/g, ' ')}"`; +} + +function mermaidId(key: string): string { + return `n_${key.replace(/[^A-Za-z0-9_]/g, '_')}`; +} + +function renderFlow(b: FlowBlock): string[] { + const out = ['```mermaid', `flowchart ${b.direction === 'down' ? 'TD' : 'LR'}`]; + for (const n of b.nodes) { + const id = mermaidId(n.key); + const label = mermaidLabel(n.label); + out.push(n.kind === 'decision' ? ` ${id}{${label}}` : n.kind === 'terminal' ? ` ${id}([${label}])` : ` ${id}[${label}]`); + } + for (const e of b.edges) { + const dashed = e.kind === 'error' || e.kind === 'async' || e.kind === 'callback'; + const label = e.label ?? (e.kind === 'branch_true' ? 'yes' : e.kind === 'branch_false' ? 'no' : undefined); + const arrow = dashed ? '-.->' : '-->'; + out.push(` ${mermaidId(e.from)} ${arrow}${label ? `|${mermaidLabel(label)}|` : ''} ${mermaidId(e.to)}`); + } + for (const status of ['added', 'removed', 'modified'] as const) { + const ids = b.nodes.filter((n) => n.status === status).map((n) => mermaidId(n.key)); + if (ids.length > 0) out.push(` class ${ids.join(',')} ${status}`); + } + out.push(' classDef added stroke:#2da44e,stroke-width:2px'); + out.push(' classDef removed stroke:#cf222e,stroke-width:2px,stroke-dasharray: 4 3'); + out.push(' classDef modified stroke:#bf8700,stroke-width:2px'); + out.push('```'); + const pinned = b.nodes.filter((n) => n.pins && n.pins.length > 0); + if (pinned.length > 0) { + out.push(''); + for (const n of pinned) out.push(`- **${mdEscape(n.label)}** — ${n.pins!.map(pinText).join(', ')}${n.origin ? ` · ${n.origin}` : ''}`); + } + return out; +} + +function renderSequence(b: SequenceBlock): string[] { + const out = ['```mermaid', 'sequenceDiagram']; + for (const a of b.actors) out.push(` participant ${mermaidId(a.key)} as ${a.label.replace(/[\n;]/g, ' ')}`); + for (const s of b.steps) { + const arrow = s.style === 'return' ? '-->>' : s.style === 'async' ? '-)' : '->>'; + out.push(` ${mermaidId(s.from)}${arrow}${mermaidId(s.to)}: ${s.label.replace(/[\n;]/g, ' ')}`); + } + out.push('```'); + return out; +} + +function renderFrames(frames: StackFrame[]): string[] { + const depth = new Map(); + return frames.map((f) => { + const d = f.parent_key ? (depth.get(f.parent_key) ?? 0) + 1 : 0; + if (f.key) depth.set(f.key, d); + const via = f.via && f.via.kind !== 'call' ? ` _(via ${f.via.kind})_` : ''; + const mark = f.status === 'added' ? ' _(new)_' : f.status === 'removed' ? ' _(gone)_' : f.status === 'modified' ? ' _(edited)_' : ''; + return `${'  '.repeat(d)}${d > 0 ? '↳ ' : ''}${f.label ? `**${mdEscape(f.label)}** ` : ''}${pinText(f.pin)}${mark}${via}`; + }); +} + +/** Appended to text an agent or a person wrote, so a reader never takes it for vg's. */ +const AGENT_MARK = ' (written by an agent)'; + +function renderBlock(b: DocBlock, explain = false): string[] { + switch (b.type) { + case 'markdown': + return [renderLinks(b.text) + (b.origin === 'agent' ? AGENT_MARK : '')]; + case 'callout': { + const head = b.tone === 'risk' ? '[!CAUTION]' : b.tone === 'warning' ? '[!WARNING]' : '[!NOTE]'; + const body = (renderLinks(b.text) + (b.origin === 'agent' ? AGENT_MARK : '')).split('\n'); + const pins = b.pins && b.pins.length > 0 ? ['', `Evidence: ${b.pins.map(pinText).join(', ')}`] : []; + return [`> ${head}`, ...[...body, ...pins].map((l) => (l ? `> ${l}` : '>'))]; + } + case 'code_peek': + return [`${b.caption ? `${mdEscape(b.caption)} — ` : ''}${pinText(b.pin)}`]; + case 'divider': + return ['---']; + case 'flow': + return [`**${mdEscape(b.title)}**`, '', ...renderFlow(b)]; + case 'sequence': + return [`**${mdEscape(b.title)}**`, '', ...renderSequence(b)]; + case 'call_stack_diff': + // Explaining code as it is: one call path, no before side to compare. + if (explain) return [`**${mdEscape(b.title)}**`, '', ...renderFrames(b.head).map((f) => `${f}
`)]; + return [ + `**${mdEscape(b.title)}**`, + '', + '| Before | After |', + '| --- | --- |', + `| ${b.base_status === 'not_computed' ? '_not computed_' : b.base_status === 'absent' ? '_did not exist_' : renderFrames(b.base).join('
') || '—'} | ${renderFrames(b.head).join('
') || '—'} |`, + ]; + case 'data_store': { + const out = [`**${mdEscape(b.title)}**`, '']; + for (const s of b.stores) { + for (const c of s.collections) { + const keys = c.fields + .filter((f) => f.primary_key || f.references) + .map((f) => `\`${f.key}\`${f.primary_key ? ' (key)' : ''}${f.references ? ` → ${f.references.collection}.${f.references.field}` : ''}`); + out.push(`- **${mdEscape(c.label)}** (${mdEscape(s.label)})${c.pin ? ` ${pinText(c.pin)}` : ''}${keys.length ? ` · ${keys.join(', ')}` : ''}`); + } + } + out.push('', '| Use case | Op | Store | Collection | Field | Actor | Code |', '| --- | --- | --- | --- | --- | --- | --- |'); + for (const u of b.use_cases) { + for (const o of u.operations) { + out.push(`| ${mdEscape(u.label)} | ${o.kind} | ${o.store} | ${o.collection} | ${o.field ?? '—'} | ${o.actor} | ${pinText(o.pin)} |`); + } + } + return out; + } + case 'system_map': { + const out = [`**${mdEscape(b.title)}**`, '', '```mermaid', 'flowchart LR']; + for (const e of b.elements) out.push(` ${mermaidId(e.path)}[${mermaidLabel(`${e.label} · ${e.type}`)}]`); + for (const r of b.relationships) out.push(` ${mermaidId(r.from)} ${r.kind === 'semantic' ? '-.->' : '-->'}${r.label ? `|${mermaidLabel(r.label)}|` : ''} ${mermaidId(r.to)}`); + out.push('```'); + return out; + } + } +} + +/** Render for a pull request comment or a terminal: GitHub Markdown with Mermaid diagrams. */ +export function renderReviewDocMarkdown(doc: ReviewDoc): string { + const out: string[] = [`## ${mdEscape(doc.title)}`]; + if (doc.repos?.length) { + out.push('', `Also covers: ${doc.repos.map((r) => `\`${r.id}\`${r.name ? ` (${mdEscape(r.name)})` : ''} ${r.base_sha.slice(0, 7)}..${r.head_sha.slice(0, 7)}`).join(', ')}.`); + } + for (const s of doc.sections) { + out.push('', `### ${s.title ?? SECTION_TITLE[s.kind]}`); + for (const b of s.blocks) out.push('', ...renderBlock(b, doc.kind === 'explain')); + } + if (doc.generator.notes.length > 0) { + out.push('', '', ...doc.generator.notes.map((n) => `· ${n}`), ''); + } + return `${out.join('\n')}\n`; +} diff --git a/src/review/explain-doc.test.ts b/src/review/explain-doc.test.ts new file mode 100644 index 0000000..66c9a28 --- /dev/null +++ b/src/review/explain-doc.test.ts @@ -0,0 +1,144 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { GraphEdge, GraphNode, VgGraph } from '../schema.js'; +import type { HaileProvider } from '../engine/haile/haile-provider.js'; +import { renderReviewDocMarkdown, validateReviewDoc } from './doc.js'; +import { buildExplainDoc, explainChange } from './explain-doc.js'; +import type { GitRunner } from './git.js'; + +/** + * The explain view's host side: the symbol's span stands in for a change, + * the module is asked in explain mode, and the document around the diagrams + * is graph facts with pins that land. + */ + +const noGit: GitRunner = () => ({ stdout: '', status: 1 }); + +function node(id: string, over: Partial = {}): GraphNode { + return { + id, + kind: 'function', + name: id, + qualifiedName: id, + file: 'src/app.ts', + span: { start: 1, end: 10 }, + lang: 'ts', + importance: 0.1, + centrality: { degree: 0, pagerank: 0, betweenness: 0, eigenvector: 0 }, + area: 0, + isHub: false, + tested: false, + ...over, + }; +} + +function edge(src: string, dst: string): GraphEdge { + return { id: `call:${src}>${dst}`, kind: 'call', src, dst, resolution: 'tsc', confidence: 1 }; +} + +const save = node('save', { file: 'src/store.ts', span: { start: 40, end: 60 }, signature: 'save(order: Order)' }); +const graph = { + schemaVersion: 'vg-graph/1.1', + nodes: [node('main'), save, node('audit', { file: 'src/audit.ts', span: { start: 1, end: 5 } })], + edges: [edge('main', 'save'), edge('save', 'audit')], + areas: [{ id: 0, label: 'orders' }], +} as unknown as VgGraph; + +const stackBlock = { + type: 'call_stack_diff', + title: 'How save is reached', + primary: true, + base_status: 'not_computed', + base: [], + head: [ + { key: 'src/app.ts#main', label: 'main', pin: { side: 'head', path: 'src/app.ts', start: 1, end: 10 }, origin: 'graph' }, + { key: 'src/store.ts#save', parent_key: 'src/app.ts#main', label: 'save', pin: { side: 'head', path: 'src/store.ts', start: 40, end: 60 }, origin: 'graph' }, + ], +}; + +function provider(seen: { payload?: unknown }): HaileProvider { + return { + version: () => 'test', + classify: () => null, + reviewDiagrams: (p: unknown) => { + seen.payload = p; + return { blocks: [stackBlock], contract: [], notes: ['from the module'] }; + }, + } as HaileProvider; +} + +let root: string; +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-explain-')); + fs.mkdirSync(path.join(root, 'src')); + const lines = (n: number) => Array.from({ length: n }, (_, i) => `// ${i + 1}`).join('\n') + '\n'; + fs.writeFileSync(path.join(root, 'src/app.ts'), lines(20)); + fs.writeFileSync(path.join(root, 'src/store.ts'), lines(80)); + fs.writeFileSync(path.join(root, 'src/audit.ts'), lines(5)); +}); +afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + +describe('explainChange', () => { + it('turns the symbol span into the one changed file, read from the working tree', () => { + const c = explainChange(root, save, noGit); + expect(c.files).toEqual([{ path: 'src/store.ts', op: 'modified', addedLines: 0, removedLines: 0, hunks: [{ start: 40, end: 60 }] }]); + expect(c.baseSha).toBe(c.headSha); + }); +}); + +describe('buildExplainDoc', () => { + it('asks the module in explain mode and builds a valid explain document', () => { + const seen: { payload?: unknown } = {}; + const { doc, resolve } = buildExplainDoc({ root, graph, node: save, provider: provider(seen), run: noGit }); + expect(seen.payload).toMatchObject({ mode: 'explain', base: null, files: [{ path: 'src/store.ts', hunks: [[40, 60]] }] }); + expect(doc.kind).toBe('explain'); + expect(doc.title).toBe('Explain: save'); + expect(validateReviewDoc(doc, resolve)).toEqual([]); + expect(doc.sections.map((s) => [s.kind, s.title])).toEqual([ + ['what_why', 'What it is'], + ['design', 'How it works'], + ['implementation', 'Callers and callees'], + ]); + const what = doc.sections[0].blocks[0] as { text: string }; + expect(what.text).toContain('[save](head:src/store.ts#L40-L60) is a function'); + expect(what.text).toContain('area: orders'); + expect(what.text).toContain('1 caller, 1 callee'); + const impl = doc.sections[2].blocks.map((b) => (b as { text: string }).text).join('\n'); + expect(impl).toContain('**Called by** (1)'); + expect(impl).toContain('[main](head:src/app.ts#L1-L10)'); + expect(impl).toContain('[audit](head:src/audit.ts#L1-L5)'); + expect(doc.generator.notes[0]).toMatch(/nothing here is a change/); + expect(doc.generator.notes).toContain('from the module'); + }); + + it('renders the call path without a before column', () => { + const { doc } = buildExplainDoc({ root, graph, node: save, provider: provider({}), run: noGit }); + const md = renderReviewDocMarkdown(doc); + expect(md).not.toContain('| Before | After |'); + expect(md).toContain('**save** `src/store.ts:40-60`'); + }); + + it('without the module: facts and callers only, and the install hint', () => { + const { doc } = buildExplainDoc({ root, graph, node: save, provider: null, run: noGit }); + expect(doc.sections.map((s) => s.kind)).toEqual(['what_why', 'implementation']); + expect(doc.generator.notes.join(' ')).toContain('vg module install arch'); + }); + + it('never links a declaration whose pin does not land', () => { + const ghost = node('ghost', { file: 'src/gone.ts', span: { start: 1, end: 3 } }); + const g = { ...graph, nodes: [...graph.nodes, ghost], edges: [...graph.edges, edge('ghost', 'save')] } as VgGraph; + const { doc } = buildExplainDoc({ root, graph: g, node: save, provider: null, run: noGit }); + const impl = (doc.sections[1].blocks[0] as { text: string }).text; + expect(impl).toContain('- `ghost`'); + expect(impl).not.toContain('src/gone.ts'); + }); +}); + +describe('document kind', () => { + it('is change or explain', () => { + const { doc } = buildExplainDoc({ root, graph, node: save, provider: null, run: noGit }); + expect(validateReviewDoc({ ...doc, kind: 'scratch' }).map((i) => i.code)).toEqual(['enum']); + }); +}); diff --git a/src/review/explain-doc.ts b/src/review/explain-doc.ts new file mode 100644 index 0000000..69605c6 --- /dev/null +++ b/src/review/explain-doc.ts @@ -0,0 +1,169 @@ +/** + * The explain view: a review document for code as it is, not for a change. + * `vg show --diagram` and the VS Code "Show diagrams" command both + * build it here. + * + * The symbol's own span stands in for a change, so the Architecture module + * derives the same diagrams it derives for a review (how the code is reached, + * its flow, the data it reads and writes, where it sits). The module gets + * `mode: "explain"`, so nothing is marked new or edited and the call path has + * no before side. The text around the diagrams is graph facts only: what the + * symbol is, where it lives, and its pinned callers and callees. + */ + +import * as path from 'node:path'; +import { resolveGraphPath } from '../engine/artifacts.js'; +import { overviewOf } from '../engine/chart/server.js'; +import { readHaileSidecar } from '../engine/haile/sidecar.js'; +import type { HaileProvider } from '../engine/haile/haile-provider.js'; +import { indexFor } from '../engine/relations.js'; +import type { GraphNode, VgGraph } from '../schema.js'; +import { readDataModels } from './data-models.js'; +import { deriveDiagrams, mapPrefix, rolesOf } from './derive.js'; +import { + DOC_SCHEMA, + makePinResolver, + mdEscape, + pinLink, + plural, + sealReviewDoc, + validateReviewDoc, + withIds, + type DocBlock, + type DocSection, + type PinResolver, + type ReviewDoc, +} from './doc.js'; +import { defaultRun, gitTopLevel, isGitRepo, normalizeRemote, repoKey, type ChangeSet, type GitRunner } from './git.js'; + +/** Callers and callees listed under implementation, each. */ +export const MAX_LISTED = 12; + +export interface ExplainOptions { + /** Directory the code map was built from. */ + root: string; + graph: VgGraph; + node: GraphNode; + graphPath?: string; + provider: HaileProvider | null; + run?: GitRunner; +} + +export interface BuiltExplainDoc { + doc: ReviewDoc; + resolve: PinResolver; +} + +/** A change whose one "edit" is the symbol's span, read from the working tree. */ +export function explainChange(root: string, node: GraphNode, run: GitRunner = defaultRun): ChangeSet { + const git = isGitRepo(root, run); + const topLevel = git ? gitTopLevel(root, run) : root; + const prefix = path.relative(topLevel, root).split(path.sep).join('/'); + const rel = node.file.replace(/\\/g, '/'); + const head = git ? run(['rev-parse', 'HEAD'], root).stdout.trim() : ''; + const remoteRaw = git ? run(['config', '--get', 'remote.origin.url'], root) : null; + const sha = head || 'working-tree'; + return { + topLevel, + baseSha: sha, + headSha: sha, + mergeBase: null, + ref: null, + dirty: false, + dirtyTreeHash: null, + files: [ + { + path: prefix && prefix !== '.' ? `${prefix}/${rel}` : rel, + op: 'modified', + addedLines: 0, + removedLines: 0, + hunks: [{ start: node.span.start, end: Math.max(node.span.start, node.span.end) }], + }, + ], + remote: remoteRaw && remoteRaw.status === 0 ? normalizeRemote(remoteRaw.stdout) : null, + }; +} + +function dedupe(nodes: GraphNode[]): GraphNode[] { + const seen = new Set(); + return nodes.filter((n) => (seen.has(n.id) ? false : (seen.add(n.id), true))); +} + +/** Build and validate the explain document for one symbol. */ +export function buildExplainDoc(o: ExplainOptions): BuiltExplainDoc { + const { root, graph, node } = o; + const run = o.run ?? defaultRun; + const change = explainChange(root, node, run); + const sides = { inPlace: true }; + const resolve = makePinResolver(change, sides, run); + const prefix = mapPrefix(change, root); + const repoPath = (file: string) => (prefix ? `${prefix}/${file.replace(/\\/g, '/')}` : file.replace(/\\/g, '/')); + /** `name` linked to its declaration when the pin lands, else plain code. */ + const linked = (n: GraphNode) => { + const p = repoPath(n.file); + const lines = resolve('head', p); + const end = Math.max(n.span.start, n.span.end); + // A link label stays plain text: renderers do not format inside it. + return lines !== null && n.span.start >= 1 && end <= lines + ? `[${n.qualifiedName.replace(/[[\]`]/g, '')}](${pinLink({ side: 'head', path: p, start: n.span.start, end })})` + : `\`${n.qualifiedName.replace(/`/g, "'")}\``; + }; + + const sidecar = readHaileSidecar(resolveGraphPath(root, o.graphPath)); + const index = indexFor(graph); + const callers = dedupe(index.callers(node.id).map((x) => x.node)); + const callees = dedupe(index.callees(node.id).map((x) => x.node)); + const area = graph.areas.find((a) => a.id === node.area); + const role = rolesOf(graph, sidecar).get(node.id); + + const what: string[] = [`${linked(node)} is a ${node.kind} in \`${repoPath(node.file)}\`.`]; + if (node.signature) what.push('', `\`${node.signature.replace(/`/g, "'").replace(/\s+/g, ' ')}\``); + const facts: string[] = []; + if (role && role.role !== 'unknown') facts.push(`architecture role: ${role.role.replace(/_/g, ' ')}`); + if (area) facts.push(`area: ${mdEscape(area.label)}`); + facts.push(`${plural(callers.length, 'caller')}, ${plural(callees.length, 'callee')}`); + if (node.tested !== undefined) facts.push(node.tested ? 'reached by tests' : 'not reached by tests'); + what.push('', facts.join(' · ')); + + const models = readDataModels(change, sides, run); + const overview = sidecar ? overviewOf(graph, sidecar, o.provider) : null; + const system = change.remote ? (change.remote.split('/').pop() ?? '') : path.basename(change.topLevel); + const design = deriveDiagrams( + { change, head: graph, base: null, mapRoot: root, resolve, models, sidecar, overview, system, mode: 'explain' }, + o.provider, + ); + + const list = (heading: string, nodes: GraphNode[]): DocBlock | null => { + if (nodes.length === 0) return null; + const lines = [`**${heading}** (${nodes.length})`, '', ...nodes.slice(0, MAX_LISTED).map((n) => `- ${linked(n)}`)]; + if (nodes.length > MAX_LISTED) lines.push(`- +${nodes.length - MAX_LISTED} more — \`vg show ${node.qualifiedName}\` lists them`); + return { type: 'markdown', text: lines.join('\n') }; + }; + const impl = [list('Called by', callers), list('Calls', callees)].filter((b): b is DocBlock => b !== null); + + const sections: DocSection[] = [{ kind: 'what_why', title: 'What it is', blocks: withIds([{ type: 'markdown', text: what.join('\n') }]) }]; + if (design.blocks.length > 0) sections.push({ kind: 'design', title: 'How it works', blocks: withIds(design.blocks) }); + if (impl.length > 0) sections.push({ kind: 'implementation', title: 'Callers and callees', blocks: withIds(impl) }); + + const notes = ['explains the code as it is in the working tree; nothing here is a change', ...design.notes]; + const doc = sealReviewDoc({ + schema_version: DOC_SCHEMA, + kind: 'explain', + title: `Explain: ${node.qualifiedName}`.slice(0, 300), + target: { + repo_key: repoKey(change.remote, change.topLevel), + base_sha: change.baseSha, + head_sha: change.headSha, + merge_base: null, + dirty_tree_hash: null, + }, + sections, + groups_digest: null, + generator: { by: 'vg', notes }, + }); + const issues = validateReviewDoc(doc, resolve); + if (issues.length > 0) { + throw new Error(`internal: generated explain document failed validation — ${issues[0].path}: ${issues[0].message}`); + } + return { doc, resolve }; +} diff --git a/src/review/git.ts b/src/review/git.ts index 791eb93..030351a 100644 --- a/src/review/git.ts +++ b/src/review/git.ts @@ -97,8 +97,12 @@ function parseNumstat(out: string): Map= 4 ? parts[3] : parts[2]; + // A rename is emitted as `added\tremoved\told\tnew` under `-z`, but in + // the default output git folds it into one column: `dir/{old => new}/f` + // or `old => new`. Expand that to the new path, or the rename is listed + // twice — once here under the folded name, once by `--name-status`. + const folded = parts.length >= 4 ? null : renamedNewPath(parts[2]); + const path = parts.length >= 4 ? parts[3] : (folded ?? parts[2]); // The counts cannot tell a deletion from a modification that only removes // lines — both print `0\tN` — so the op derived here is never `removed` // (nor `added` for `N\t0`). It is only the fallback for a path the status @@ -107,12 +111,27 @@ function parseNumstat(out: string): Map= 4 ? 'renamed' : 'modified'; + const op: ChangedFile['op'] = parts.length >= 4 || folded !== null ? 'renamed' : 'modified'; map.set(path, { added, removed, op }); } return map; } +/** + * The new-side path of a rename as plain `git diff --numstat` prints it: + * `src/{a => b}/x.ts` → `src/b/x.ts`, `{old => }/x` → `x`, `a.ts => b.ts` → `b.ts`. + * Null when the column is not a rename. + */ +export function renamedNewPath(column: string): string | null { + const brace = column.match(/^(.*)\{([^{}]*) => ([^{}]*)\}(.*)$/); + if (brace) { + const joined = `${brace[1]}${brace[3]}${brace[4]}`; + return joined.replace(/\/\/+/g, '/').replace(/^\//, ''); + } + const plain = column.match(/^(.+) => (.+)$/); + return plain ? plain[2] : null; +} + function opFromStatusLetters(letters: string): ChangedFile['op'] { if (letters.includes('R')) return 'renamed'; if (letters.includes('A') || letters.includes('?')) return 'added'; diff --git a/src/review/groups.test.ts b/src/review/groups.test.ts new file mode 100644 index 0000000..5d79dfe --- /dev/null +++ b/src/review/groups.test.ts @@ -0,0 +1,246 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { HaileProvider } from '../engine/haile/haile-provider.js'; +import { collectChangeSet, renamedNewPath, type ChangeSet, type ChangedFile, type GitRunner } from './git.js'; +import { + collectGroupSignals, + emptySignals, + fileFacts, + groupChangeSet, + groupsPayload, + UNGROUPED_NOTE, + validateGroups, + type DiffGroup, +} from './groups.js'; + +/** + * The host side of diff groups. Which file lands in which group is decided by + * the Architecture module and tested there; these tests pin what the host + * owns: the facts and signals it sends, the partition it enforces, and the + * honest fallback when the module is missing. + */ + +function file(p: string, over: Partial = {}): ChangedFile { + return { path: p, op: 'modified', addedLines: 3, removedLines: 1, hunks: [{ start: 1, end: 3 }], ...over }; +} + +function change(files: ChangedFile[]): ChangeSet { + return { + topLevel: '/repo', + baseSha: 'b'.repeat(40), + headSha: 'h'.repeat(40), + mergeBase: 'b'.repeat(40), + ref: null, + dirty: false, + dirtyTreeHash: null, + files, + remote: null, + }; +} + +const groupOf = (kind: DiffGroup['kind'], id: string, paths: string[]): DiffGroup => ({ + id, + kind, + label: kind, + area: null, + layer: null, + files: paths.map((p) => ({ path: p, op: 'modified', added_lines: 3, removed_lines: 1, reason: 'test' })), + added_lines: 3 * paths.length, + removed_lines: paths.length, +}); + +function provider(groups: DiffGroup[] | null, seen?: { payload?: unknown }): HaileProvider { + return { + version: () => 'test', + classify: () => null, + reviewGroups: (payload) => { + if (seen) seen.payload = payload; + return groups ? { groups } : null; + }, + }; +} + +describe('fileFacts', () => { + it('reports what the public code map already knows about a path', () => { + expect(fileFacts('src/order.test.ts')).toMatchObject({ test: true }); + expect(fileFacts('pnpm-lock.yaml')).toMatchObject({ lockfile: true }); + expect(fileFacts('package.json')).toMatchObject({ manifest: true }); + expect(fileFacts('vitest.config.ts')).toMatchObject({ tooling: true }); + expect(fileFacts('proto/orders.proto')).toMatchObject({ contract: true }); + expect(fileFacts('src/controllers/orderController.ts').layer).toBeTruthy(); + expect(fileFacts('zzz/qqq.ts')).not.toHaveProperty('test'); + }); +}); + +describe('groupsPayload', () => { + it('sends each file with its facts and only the signals that apply', () => { + const s = emptySignals(); + s.whitespaceOnly.add('src/a.ts'); + s.changedLines.set('src/b.ts', { added: ["import x from './x';"], removed: [] }); + s.headHeader.set('src/b.ts', '// header'); + const p = groupsPayload(change([file('src/a.ts'), file('src/b.ts')]), s); + expect(p.files).toEqual([ + expect.objectContaining({ path: 'src/a.ts', op: 'modified', added_lines: 3, removed_lines: 1, whitespace_only: true }), + expect.objectContaining({ path: 'src/b.ts', header: '// header', added: ["import x from './x';"], removed: [] }), + ]); + expect(p.files[0]).not.toHaveProperty('header'); + }); +}); + +describe('groupChangeSet', () => { + const c = change([file('src/a.ts'), file('src/a.test.ts'), file('README.md')]); + const good = [groupOf('implementation', 'grp:i', ['src/a.ts']), groupOf('tests', 'grp:t', ['src/a.test.ts']), groupOf('docs', 'grp:d', ['README.md'])]; + + it('uses the module\'s groups when they cover the change exactly', () => { + const seen: { payload?: unknown } = {}; + const g = groupChangeSet(c, emptySignals(), provider(good, seen)); + expect(g.groups.map((x) => x.kind)).toEqual(['implementation', 'tests', 'docs']); + expect(g.counts).toEqual({ files: 3, groups: 3, implementation_files: 1, peeled_files: 2 }); + expect(g.note).toBeNull(); + expect((seen.payload as { files: unknown[] }).files).toHaveLength(3); + expect(validateGroups(g, c)).toEqual([]); + }); + + it('refuses module groups that drop or duplicate a file, and falls back to one honest group', () => { + const missing = groupChangeSet(c, emptySignals(), provider(good.slice(0, 2))); + expect(missing.groups.map((x) => x.kind)).toEqual(['ungrouped']); + expect(missing.note).toMatch(/do not cover this change exactly/); + const dup = groupChangeSet(c, emptySignals(), provider([...good, groupOf('docs', 'grp:d2', ['README.md'])])); + expect(dup.groups.map((x) => x.kind)).toEqual(['ungrouped']); + }); + + it('without the module: every file in one "not grouped" group, and the install command', () => { + const g = groupChangeSet(c); + expect(g.groups).toHaveLength(1); + expect(g.groups[0]).toMatchObject({ kind: 'ungrouped', label: 'Changed files (not grouped)' }); + expect(g.groups[0].files.map((f) => f.path)).toEqual(['README.md', 'src/a.test.ts', 'src/a.ts']); + expect(g.counts.implementation_files).toBe(3); + expect(g.note).toBe(UNGROUPED_NOTE); + expect(validateGroups(g, c)).toEqual([]); + }); + + it('with a module that predates diff groups: says so', () => { + expect(groupChangeSet(c, emptySignals(), { version: () => 'old', classify: () => null }).note).toMatch(/predates diff groups/); + }); + + it('survives a module that abstains or throws', () => { + expect(groupChangeSet(c, emptySignals(), provider(null)).groups[0].kind).toBe('ungrouped'); + const throwing: HaileProvider = { version: () => 'x', classify: () => null, reviewGroups: () => { throw new Error('boom'); } }; + expect(groupChangeSet(c, emptySignals(), throwing).groups[0].kind).toBe('ungrouped'); + }); + + it('is deterministic, and an empty change has no groups', () => { + expect(JSON.stringify(groupChangeSet(c, emptySignals(), provider(good)))).toBe(JSON.stringify(groupChangeSet(c, emptySignals(), provider(good)))); + expect(groupChangeSet(change([])).groups).toEqual([]); + }); +}); + +describe('validateGroups — uncategorized fails', () => { + const c = change([file('src/a.ts'), file('src/b.ts')]); + + it('reports a file left out, a duplicate, a stranger, an empty group and a bad kind', () => { + const g = groupChangeSet(c); + const edited = { + ...g, + groups: [ + { id: 'grp:x', kind: 'implementation', label: 'A', files: [{ path: 'src/a.ts' }, { path: 'src/a.ts' }, { path: 'src/zzz.ts' }] }, + { id: 'grp:x', kind: 'mystery', label: 'B', files: [] }, + ], + }; + const codes = validateGroups(edited, c).map((i) => i.code).sort(); + expect(codes).toEqual(['duplicate_file', 'duplicate_id', 'empty_group', 'not_in_change', 'uncategorized', 'unknown_kind']); + }); + + it('accepts an agent merge of two groups', () => { + const g = groupChangeSet(c); + const merged = { ...g, groups: [{ id: 'grp:merged', kind: 'implementation', label: 'All', files: g.groups.flatMap((x) => x.files) }] }; + expect(validateGroups(merged, c)).toEqual([]); + }); + + it('flags groups written for a different change', () => { + const g = groupChangeSet(c); + const stale = { ...g, target: { ...g.target, head_sha: 'f'.repeat(40) } }; + expect(validateGroups(stale, c).map((i) => i.code)).toContain('stale_target'); + }); + + it('rejects a non-object', () => { + expect(validateGroups(null, c)[0].code).toBe('not_object'); + }); +}); + +describe('renamedNewPath', () => { + it.each([ + ['packages/web/post/{outbox => posted}/a.json', 'packages/web/post/posted/a.json'], + ['src/{a => b}.ts', 'src/b.ts'], + ['{old => }/x.ts', 'x.ts'], + ['src/{ => nested}/x.ts', 'src/nested/x.ts'], + ['a.ts => b.ts', 'b.ts'], + ])('%s → %s', (input, expected) => expect(renamedNewPath(input)).toBe(expected)); + + it('returns null for an ordinary path', () => { + expect(renamedNewPath('src/a.ts')).toBeNull(); + }); +}); + +describe('signals from a real repository', () => { + const roots: string[] = []; + afterEach(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); + }); + const run: GitRunner = (args, cwd) => { + const res = spawnSync('git', args, { cwd, encoding: 'utf8' }); + return { stdout: res.stdout ?? '', status: res.status ?? 1 }; + }; + const git = (cwd: string, ...args: string[]) => { + const res = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (res.status !== 0) throw new Error(`git ${args.join(' ')}: ${res.stderr}`); + }; + const write = (root: string, rel: string, text: string) => { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), text); + }; + + it('collects whitespace-only, changed lines, generated attributes and headers; a rename with edits is one entry', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-groups-')); + roots.push(root); + git(root, 'init', '-q', '-b', 'main'); + git(root, 'config', 'user.email', 'test@example.com'); + git(root, 'config', 'user.name', 'Test'); + const body = Array.from({ length: 30 }, (_, i) => `export const v${i} = ${i};`).join('\n'); + write(root, 'src/services/fmt.ts', 'export function f(a: number) {\n return a + 1;\n}\n'); + write(root, 'src/services/imp.ts', "import { a } from './a.js';\nexport const x = a;\n"); + write(root, 'src/services/logic.ts', 'export function g() {\n return 1;\n}\n'); + write(root, 'src/gen/client.ts', 'export const c = 1;\n'); + write(root, 'src/inbox/moved.ts', `${body}\n`); + write(root, '.gitattributes', 'src/gen/** linguist-generated=true\n'); + git(root, 'add', '-A'); + git(root, 'commit', '-q', '-m', 'base'); + git(root, 'checkout', '-q', '-b', 'feature'); + + write(root, 'src/services/fmt.ts', 'export function f(a: number) {\n return a + 1;\n}\n\n'); + write(root, 'src/services/imp.ts', "import { a } from './a2.js';\nexport const x = a;\n"); + write(root, 'src/services/logic.ts', 'export function g() {\n return 2;\n}\n'); + write(root, 'src/gen/client.ts', 'export const c = 2;\n'); + fs.mkdirSync(path.join(root, 'src/outbox'), { recursive: true }); + fs.renameSync(path.join(root, 'src/inbox/moved.ts'), path.join(root, 'src/outbox/moved.ts')); + write(root, 'src/outbox/moved.ts', `${body}\nexport const extra = 1;\n`); + git(root, 'add', '-A'); + git(root, 'commit', '-q', '-m', 'change'); + + const cs = collectChangeSet(root, 'main', run); + const moved = cs.files.filter((f) => f.path.includes('moved.ts')); + // One entry, under the new path, with the edit's line counts — not two. + expect(moved).toHaveLength(1); + expect(moved[0]).toMatchObject({ path: 'src/outbox/moved.ts', op: 'renamed', addedLines: 1 }); + + const signals = collectGroupSignals(cs, { base: 'main' }, run); + expect(signals.whitespaceOnly.has('src/services/fmt.ts')).toBe(true); + expect(signals.whitespaceOnly.has('src/services/logic.ts')).toBe(false); + expect(signals.changedLines.get('src/services/imp.ts')).toEqual({ added: ["import { a } from './a2.js';"], removed: ["import { a } from './a.js';"] }); + expect(signals.generatedAttr.has('src/gen/client.ts')).toBe(true); + expect(signals.headHeader.get('src/services/logic.ts')).toContain('return 2'); + }); +}); diff --git a/src/review/groups.ts b/src/review/groups.ts new file mode 100644 index 0000000..b3358b5 --- /dev/null +++ b/src/review/groups.ts @@ -0,0 +1,465 @@ +/** + * Diff groups — `vg.review.groups.v1`, the host side. + * + * Partitions a change set into groups a reader can take in one sitting, in + * reading order: implementation by area and architecture layer first, then + * everything that is not implementation, peeled off with a reason. + * + * Which rule places a file where — the peel order, the path and content + * rules, import detection, areas, layer order, how big groups split — is + * decided by the Architecture module (`HaileProvider.reviewGroups`). This file + * only collects what the module reads (git signals, and facts the public + * code map already computes for every file), checks that what comes back is + * a partition of the change, and seals it with a digest. + * + * Without the module, the change is one honest "not grouped" group: every + * file still listed, nothing guessed. {@link validateGroups} enforces the + * partition on any groups file an agent or person edits. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { classifyFile } from '../core-open/scanners/architecture/classify.js'; +import { isTestFilePath, isToolingConfigFile } from '../core-open/scanners/architecture/folders.js'; +import { isContractOrIacFile } from '../core-open/scanners/architecture/walker.js'; +import { SKIP_FILES } from '../engine/discover.js'; +import { isTestFile } from '../engine/tests.js'; +import type { HaileProvider } from '../engine/haile/haile-provider.js'; +import { defaultRun, type ChangeSet, type ChangedFile, type GitRunner } from './git.js'; +import { digest } from './schemas.js'; +import { isDependencyManifest } from './surface.js'; + +export const GROUPS_SCHEMA = 'vg.review.groups.v1' as const; + +export type GroupKind = + | 'implementation' + | 'tests' + | 'fixtures' + | 'data' + | 'config' + | 'dependencies' + | 'generated' + | 'docs' + | 'assets' + | 'moved' + | 'formatting' + | 'imports' + /** Every changed file, unsorted — the Architecture module was not available. */ + | 'ungrouped'; + +/** Every kind a groups file may carry, in reading order. */ +export const GROUP_KIND_ORDER: readonly GroupKind[] = [ + 'implementation', + 'tests', + 'fixtures', + 'data', + 'config', + 'dependencies', + 'generated', + 'docs', + 'assets', + 'moved', + 'formatting', + 'imports', + 'ungrouped', +]; + +/** Kinds a reviewer reads line by line; everything else is peeled off. */ +export const READ_KINDS: ReadonlySet = new Set(['implementation', 'ungrouped']); + +export const UNGROUPED_NOTE = 'files are not grouped — grouping needs the Architecture module: run `vg module install arch`'; + +export interface GroupFile { + path: string; + op: ChangedFile['op']; + added_lines: number; + removed_lines: number; + /** The deterministic signal that placed the file here. */ + reason: string; +} + +export interface DiffGroup { + /** Content-derived: `grp::`. */ + id: string; + kind: GroupKind; + label: string; + /** Monorepo area (`packages/api`), or null at the repository root. */ + area: string | null; + /** Architecture layer for implementation groups; null otherwise. */ + layer: string | null; + files: GroupFile[]; + added_lines: number; + removed_lines: number; +} + +export interface DiffGroups { + schema_version: typeof GROUPS_SCHEMA; + target: { + base_sha: string; + head_sha: string; + merge_base: string | null; + dirty_tree_hash: string | null; + }; + groups: DiffGroup[]; + counts: { files: number; groups: number; implementation_files: number; peeled_files: number }; + /** Why the files are not grouped, when they are not; null when the module grouped them. */ + note: string | null; + /** `sha256:` over every field above. */ + digest: string; +} + +/** Signals read from git and the working tree. */ +export interface GroupSignals { + /** Changed line text per path, from a zero-context diff. */ + changedLines: Map; + /** Paths whose diff is empty once whitespace and blank lines are ignored. */ + whitespaceOnly: Set; + /** Paths git marks `linguist-generated` (via `.gitattributes`). */ + generatedAttr: Set; + /** The first bytes of each changed file on the head side. */ + headHeader: Map; +} + +export function emptySignals(): GroupSignals { + return { changedLines: new Map(), whitespaceOnly: new Set(), generatedAttr: new Set(), headHeader: new Map() }; +} + +// ─── What the module reads ────────────────────────────────────────────────── + +/** + * Facts the public code map already knows about a path, from the same helpers + * the graph uses: test linkage, dependency files, tooling config, contracts + * and infrastructure files, and the architecture layer. + */ +export function fileFacts(p: string): Record { + const rel = p.replace(/\\/g, '/'); + const base = (rel.split('/').pop() ?? rel).toLowerCase(); + const facts: Record = {}; + if (isTestFile(rel) || isTestFilePath(rel)) facts.test = true; + if (SKIP_FILES.has(base)) facts.lockfile = true; + if (isDependencyManifest(rel)) facts.manifest = true; + if (isToolingConfigFile(rel)) facts.tooling = true; + if (isContractOrIacFile(rel, rel.split('/').pop() ?? rel)) facts.contract = true; + const cls = classifyFile(rel, 'unknown'); + if (cls) { + facts.layer = cls.layer; + facts.layer_signal = cls.signals[0] ?? cls.source; + } + return facts; +} + +/** The payload `HaileProvider.reviewGroups` reads. */ +export function groupsPayload(change: ChangeSet, signals: GroupSignals): { files: unknown[] } { + return { + files: change.files.map((f) => { + const p = f.path.replace(/\\/g, '/'); + const lines = signals.changedLines.get(p); + return { + path: p, + op: f.op, + added_lines: f.addedLines, + removed_lines: f.removedLines, + facts: fileFacts(p), + ...(signals.whitespaceOnly.has(p) ? { whitespace_only: true } : {}), + ...(signals.generatedAttr.has(p) ? { generated_attr: true } : {}), + ...(signals.headHeader.has(p) ? { header: signals.headHeader.get(p) } : {}), + ...(lines ? { added: lines.added, removed: lines.removed } : {}), + }; + }), + }; +} + +function cmp(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +function ungrouped(change: ChangeSet): DiffGroup[] { + if (change.files.length === 0) return []; + const files: GroupFile[] = [...change.files] + .sort((a, b) => cmp(a.path, b.path)) + .map((f) => ({ path: f.path.replace(/\\/g, '/'), op: f.op, added_lines: f.addedLines, removed_lines: f.removedLines, reason: 'not grouped' })); + return [ + { + id: 'grp:ungrouped:all', + kind: 'ungrouped', + label: 'Changed files (not grouped)', + area: null, + layer: null, + files, + added_lines: files.reduce((n, f) => n + f.added_lines, 0), + removed_lines: files.reduce((n, f) => n + f.removed_lines, 0), + }, + ]; +} + +/** + * Build the groups for a change set. The module decides; the host checks + * that every changed file is in exactly one group before trusting the answer. + */ +export function groupChangeSet( + change: ChangeSet, + signals: GroupSignals = emptySignals(), + provider: HaileProvider | null = null, +): DiffGroups { + let groups: DiffGroup[] | null = null; + let note: string | null = null; + if (change.files.length === 0) { + groups = []; + } else if (provider?.reviewGroups) { + let raw: ReturnType> = null; + try { + raw = provider.reviewGroups(groupsPayload(change, signals)); + } catch { + raw = null; + } + const candidate = raw ? (raw.groups as DiffGroup[]) : null; + if (candidate && validateGroups({ schema_version: GROUPS_SCHEMA, groups: candidate }, change).length === 0) { + groups = candidate; + } else { + note = 'files are not grouped — the Architecture module returned groups that do not cover this change exactly'; + } + } else { + note = provider ? `${UNGROUPED_NOTE} (the installed module predates diff groups)` : UNGROUPED_NOTE; + } + if (!groups) groups = ungrouped(change); + + const total = change.files.length; + const readFiles = groups.filter((g) => READ_KINDS.has(g.kind)).reduce((n, g) => n + g.files.length, 0); + const body = { + schema_version: GROUPS_SCHEMA, + target: { + base_sha: change.baseSha, + head_sha: change.headSha, + merge_base: change.mergeBase, + dirty_tree_hash: change.dirtyTreeHash, + }, + groups, + counts: { files: total, groups: groups.length, implementation_files: readFiles, peeled_files: total - readFiles }, + note, + }; + return { ...body, digest: digest(body) }; +} + +// ─── Validation ───────────────────────────────────────────────────────────── + +export interface GroupIssue { + path: string; + code: string; + message: string; +} + +/** + * Check that a (possibly hand- or agent-edited) groups document is a partition + * of the change set: known kinds, unique ids, no empty group, every changed + * file present exactly once, and nothing that is not in the change. Anything + * left out is uncategorized, and uncategorized fails. + */ +export function validateGroups(value: unknown, change: ChangeSet): GroupIssue[] { + const issues: GroupIssue[] = []; + const doc = value as Partial | null; + if (!doc || typeof doc !== 'object') { + return [{ path: '$', code: 'not_object', message: 'groups document must be a JSON object' }]; + } + if (doc.schema_version !== GROUPS_SCHEMA) { + issues.push({ path: '$.schema_version', code: 'schema', message: `expected ${GROUPS_SCHEMA}` }); + } + if (doc.target && (doc.target.base_sha !== change.baseSha || doc.target.head_sha !== change.headSha)) { + issues.push({ + path: '$.target', + code: 'stale_target', + message: `groups were made for ${short(doc.target.base_sha)}..${short(doc.target.head_sha)}, the change is ${short(change.baseSha)}..${short(change.headSha)}`, + }); + } + if (!Array.isArray(doc.groups)) { + issues.push({ path: '$.groups', code: 'missing', message: 'groups must be an array' }); + return issues; + } + const expected = new Set(change.files.map((f) => f.path.replace(/\\/g, '/'))); + const seen = new Map(); + const ids = new Set(); + doc.groups.forEach((g, i) => { + const at = `$.groups[${i}]`; + if (!g || typeof g !== 'object') { + issues.push({ path: at, code: 'not_object', message: 'group must be an object' }); + return; + } + if (typeof g.id !== 'string' || !g.id) issues.push({ path: `${at}.id`, code: 'missing', message: 'group id is required' }); + else if (ids.has(g.id)) issues.push({ path: `${at}.id`, code: 'duplicate_id', message: `duplicate group id ${g.id}` }); + else ids.add(g.id); + if (!GROUP_KIND_ORDER.includes(g.kind as GroupKind)) { + issues.push({ path: `${at}.kind`, code: 'unknown_kind', message: `unknown kind ${String(g.kind)}` }); + } + if (typeof g.label !== 'string' || !g.label.trim()) { + issues.push({ path: `${at}.label`, code: 'missing', message: 'group label is required' }); + } + if (!Array.isArray(g.files) || g.files.length === 0) { + issues.push({ path: `${at}.files`, code: 'empty_group', message: 'a group must hold at least one file' }); + return; + } + g.files.forEach((f, j) => { + const p = typeof f?.path === 'string' ? f.path.replace(/\\/g, '/') : ''; + if (!p) { + issues.push({ path: `${at}.files[${j}]`, code: 'missing', message: 'file path is required' }); + return; + } + if (!expected.has(p)) { + issues.push({ path: `${at}.files[${j}]`, code: 'not_in_change', message: `${p} is not part of this change` }); + } else if (seen.has(p)) { + issues.push({ path: `${at}.files[${j}]`, code: 'duplicate_file', message: `${p} is already in ${seen.get(p)}` }); + } else { + seen.set(p, String(g.id)); + } + }); + }); + for (const p of [...expected].sort(cmp)) { + if (!seen.has(p)) issues.push({ path: '$.groups', code: 'uncategorized', message: `${p} is in no group` }); + } + return issues; +} + +function short(sha: string | undefined | null): string { + return sha ? sha.slice(0, 7) : '(none)'; +} + +// ─── Signal collection (git + filesystem) ─────────────────────────────────── + +/** The git diff range collectChangeSet used, so every signal reads the same change. */ +export function diffRange(change: ChangeSet, opts: { base?: string; inPlace?: boolean }): string[] { + if (opts.base && opts.inPlace) return [change.baseSha]; + if (opts.base) return [`${change.baseSha}..HEAD`]; + return ['HEAD']; +} + +/** True when the head side of the change is the working tree rather than a commit. */ +export function headIsWorkingTree(opts: { base?: string; inPlace?: boolean }): boolean { + return !opts.base || Boolean(opts.inPlace); +} + +/** Per-path added/removed line text from a zero-context unified diff. */ +export function changedLinesFromDiff(diff: string): Map { + const out = new Map(); + let oldPath: string | null = null; + let current: { added: string[]; removed: string[] } | null = null; + for (const line of diff.split('\n')) { + if (line.startsWith('diff --git ')) { + current = null; + oldPath = null; + continue; + } + if (line.startsWith('--- ')) { + const p = line.slice(4).trim(); + oldPath = p === '/dev/null' ? null : p.replace(/^a\//, ''); + continue; + } + if (line.startsWith('+++ ')) { + const p = line.slice(4).trim(); + const key = p === '/dev/null' ? oldPath : p.replace(/^b\//, ''); + if (!key) { + current = null; + continue; + } + if (!out.has(key)) out.set(key, { added: [], removed: [] }); + current = out.get(key)!; + continue; + } + if (!current || line.startsWith('@@')) continue; + if (line.startsWith('+')) current.added.push(line.slice(1)); + else if (line.startsWith('-')) current.removed.push(line.slice(1)); + } + return out; +} + +const HEADER_BYTES = 1024; + +function readHeader(abs: string): string | null { + try { + const fd = fs.openSync(abs, 'r'); + try { + const buf = Buffer.alloc(HEADER_BYTES); + const n = fs.readSync(fd, buf, 0, HEADER_BYTES, 0); + return buf.subarray(0, n).toString('utf8'); + } finally { + fs.closeSync(fd); + } + } catch { + return null; + } +} + +/** + * Read every signal the classifier needs, with a bounded number of git calls: + * one zero-context diff, one whitespace-insensitive numstat, one attribute + * query, and a header read per changed file. + */ +export function collectGroupSignals( + change: ChangeSet, + opts: { base?: string; inPlace?: boolean }, + run: GitRunner = defaultRun, +): GroupSignals { + const signals = emptySignals(); + if (change.files.length === 0) return signals; + const cwd = change.topLevel; + const range = diffRange(change, opts); + + signals.changedLines = changedLinesFromDiff(run(['diff', '-U0', '-M', ...range], cwd).stdout); + + const ws = run(['diff', '--numstat', '-M', '-w', '--ignore-blank-lines', ...range], cwd); + if (ws.status === 0) { + const stillChanged = new Set(); + for (const line of ws.stdout.split('\n')) { + const parts = line.split('\t'); + if (parts.length < 3) continue; + if (parts[0] === '0' && parts[1] === '0') continue; + stillChanged.add(parts.length >= 4 ? parts[3] : parts[2]); + } + for (const f of change.files) { + const p = f.path.replace(/\\/g, '/'); + // Only a file git listed in the plain diff can be judged; an untracked + // file is absent from both and is not "formatting only". + if (f.op === 'modified' && signals.changedLines.has(p) && !stillChanged.has(p)) signals.whitespaceOnly.add(p); + } + } + + const paths = change.files.map((f) => f.path.replace(/\\/g, '/')); + for (let i = 0; i < paths.length; i += 200) { + const res = run(['check-attr', 'linguist-generated', '--', ...paths.slice(i, i + 200)], cwd); + if (res.status !== 0) continue; + for (const line of res.stdout.split('\n')) { + const m = line.match(/^(.*): linguist-generated: (set|true)$/); + if (m) signals.generatedAttr.add(m[1]); + } + } + + const fromTree = headIsWorkingTree(opts); + for (const f of change.files) { + if (f.op === 'removed') continue; + const p = f.path.replace(/\\/g, '/'); + if (fromTree) { + const h = readHeader(path.join(cwd, p)); + if (h !== null) signals.headHeader.set(p, h); + } else { + const res = run(['show', `${change.headSha}:${p}`], cwd); + if (res.status === 0) signals.headHeader.set(p, res.stdout.slice(0, HEADER_BYTES)); + } + } + return signals; +} + +// ─── Text output ──────────────────────────────────────────────────────────── + +export function formatGroupsText(groups: DiffGroups): string { + const lines: string[] = []; + const c = groups.counts; + lines.push( + `${c.files} changed file${c.files === 1 ? '' : 's'} in ${c.groups} group${c.groups === 1 ? '' : 's'} — ${c.implementation_files} implementation, ${c.peeled_files} peeled off`, + ); + if (groups.note) lines.push(groups.note); + for (const g of groups.groups) { + lines.push(''); + lines.push(`${g.label} (+${g.added_lines} −${g.removed_lines})`); + for (const f of g.files) { + lines.push(` ${f.op.padEnd(8)} ${f.path} +${f.added_lines} −${f.removed_lines} · ${f.reason}`); + } + } + return lines.join('\n'); +} diff --git a/src/review/multi-repo.test.ts b/src/review/multi-repo.test.ts new file mode 100644 index 0000000..575ecff --- /dev/null +++ b/src/review/multi-repo.test.ts @@ -0,0 +1,115 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { buildDocument, resolveScope, scopeResolver } from './doc-build.js'; +import { checkAgainstChange } from './doc-store.js'; +import { parsePinLink, pinLink, renderReviewDocMarkdown, validateReviewDoc, type DocBlock, type ReviewDoc } from './doc.js'; +import { repoKey } from './git.js'; +import { mergeDocuments, multiResolver, parseAlso, repoId, tagBlock } from './multi-repo.js'; + +/** + * Multi-repo review documents (`vg review doc --also`): each repository is + * built where it lives, then folded in with every pin naming its repository, + * and checked pin by pin against the repository it came from. + */ + +describe('pins that name a repository', () => { + it('round-trip through links', () => { + expect(parsePinLink('head@client:src/a.ts#L3-L4')).toEqual({ side: 'head', path: 'src/a.ts', start: 3, end: 4, repo: 'client' }); + expect(pinLink({ side: 'base', path: 'a.ts', start: 2, end: 2, repo: 'client' })).toBe('base@client:a.ts#L2'); + expect(parsePinLink('head:src/a.ts#L3')).toEqual({ side: 'head', path: 'src/a.ts', start: 3, end: 3 }); + }); + + it('must name one of the document repos', () => { + const doc = { + schema_version: 'vg.review.doc.v1', + title: 't', + target: { repo_key: null, base_sha: 'a', head_sha: 'b', merge_base: null, dirty_tree_hash: null }, + sections: [{ kind: 'what_why', blocks: [{ type: 'markdown', text: 'see [x](head@ghost:a.ts#L1)' }] }], + groups_digest: null, + generator: { by: 'vg', notes: [] }, + }; + expect(validateReviewDoc(doc).map((i) => i.code)).toContain('pin_repo'); + const named = { ...doc, repos: [{ id: 'ghost', name: null, repo_key: 'k', base_sha: 'a', head_sha: 'b', merge_base: null, dirty_tree_hash: null }] }; + expect(validateReviewDoc(named)).toEqual([]); + expect(validateReviewDoc({ ...named, repos: [named.repos[0], named.repos[0]] }).map((i) => i.code)).toContain('duplicate_id'); + }); +}); + +describe('folding documents together', () => { + it('reads --also specs and gives each repository a short unique id', () => { + expect(parseAlso('../client=origin/main')).toEqual({ dir: '../client', base: 'origin/main' }); + expect(parseAlso('../client')).toEqual({ dir: '../client', base: null }); + expect(repoId('acme/Web.Client', '../x', new Set())).toBe('web.client'); + expect(repoId(null, '/work/Client App', new Set(['client-app']))).toBe('client-app-2'); + }); + + it('tags every pin and pin link in a block, and never keeps it primary', () => { + const block = { + type: 'call_stack_diff', + id: 'blk_1', + primary: true, + title: 'How get is reached', + base: [], + head: [{ key: 'k', label: 'get', pin: { side: 'head', path: 'src/a.ts', start: 1, end: 3 }, call_site: { side: 'head', path: 'src/b.ts', start: 2, end: 2 } }], + } as unknown as DocBlock; + const tagged = tagBlock(block, 'client') as unknown as { id?: string; primary: boolean; head: { pin: { repo: string }; call_site: { repo: string } }[] }; + expect(tagged.id).toBeUndefined(); + expect(tagged.primary).toBe(false); + expect(tagged.head[0].pin.repo).toBe('client'); + expect(tagged.head[0].call_site.repo).toBe('client'); + expect((tagBlock({ type: 'markdown', text: 'a [L2](head:src/a.ts#L2) b' }, 'client') as { text: string }).text).toBe('a [L2](head@client:src/a.ts#L2) b'); + }); +}); + +describe('a change across two checkouts', () => { + const roots: string[] = []; + afterEach(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); + }); + const git = (cwd: string, ...args: string[]) => { + const res = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (res.status !== 0) throw new Error(`git ${args.join(' ')}: ${res.stderr}`); + }; + function repo(name: string, lines: number): string { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), `vg-multi-${name}-`))); + roots.push(root); + git(root, 'init', '-q', '-b', 'main'); + git(root, 'config', 'user.email', 'test@example.com'); + git(root, 'config', 'user.name', 'Test'); + git(root, 'remote', 'add', 'origin', `https://github.com/acme/${name}.git`); + fs.mkdirSync(path.join(root, 'src')); + fs.writeFileSync(path.join(root, 'src/a.ts'), 'export const a = 1;\n'); + git(root, 'add', '-A'); + git(root, 'commit', '-q', '-m', 'base'); + git(root, 'branch', 'base'); + fs.writeFileSync(path.join(root, 'src/a.ts'), Array.from({ length: lines }, (_, i) => `export const v${i} = ${i};`).join('\n') + '\n'); + git(root, 'commit', '-q', '-am', 'change'); + return root; + } + + it('builds, folds, checks every pin in its own repository, and catches a pin that does not land', async () => { + const api = repo('api', 3); + const client = repo('client', 9); + const opts = { findings: false, diagrams: false }; + const primary = await buildDocument(api, { kind: 'change', base: 'base', in_place: false }, opts); + const other = await buildDocument(client, { kind: 'change', base: 'base', in_place: false }, opts); + const doc = mergeDocuments(primary.doc, [{ id: 'client', name: 'acme/client', doc: other.doc }]); + const resolve = multiResolver(primary.resolve, new Map([['client', other.resolve]])); + expect(validateReviewDoc(doc, resolve)).toEqual([]); + expect(doc.repos).toMatchObject([{ id: 'client', name: 'acme/client', head_sha: other.doc.target.head_sha }]); + expect(JSON.stringify(doc)).toContain('head@client:src/a.ts#L1-L9'); + expect(renderReviewDocMarkdown(doc)).toMatch(/Also covers: `client` \(acme\/client\)/); + + // The client's L1-L9 does not fit the api's 3-line file: checked in the wrong repository it fails. + expect(validateReviewDoc(doc, primary.resolve).map((i) => i.code)).toContain('pin_missing_file'); + // --check without the other checkout says so once; with it, the document is valid. + const resolved = resolveScope(api, { kind: 'change', base: 'base', in_place: false }); + expect(checkAgainstChange(doc as ReviewDoc, resolved).map((i) => i.code)).toEqual(['repo_not_checked']); + const clientScope = resolveScope(client, { kind: 'change', base: 'base', in_place: false }); + const others = new Map([[repoKey(clientScope.change.remote, clientScope.change.topLevel), scopeResolver(clientScope)]]); + expect(checkAgainstChange(doc as ReviewDoc, resolved, others)).toEqual([]); + }); +}); diff --git a/src/review/multi-repo.ts b/src/review/multi-repo.ts new file mode 100644 index 0000000..0149507 --- /dev/null +++ b/src/review/multi-repo.ts @@ -0,0 +1,138 @@ +/** + * Multi-repo review documents: `vg review doc --also ../client` for a change + * that spans repositories (an API and the client that calls it). + * + * Each repository's document is built on its own, from its own checkout, code + * map and diff, exactly as `vg review doc` builds it there. The others are + * then folded into the first: their blocks join the same four sections under + * a heading naming the repository, and every pin they carry is tagged with + * that repository's id (`pin.repo`, `head@:path#L1` in text), so each pin + * is still checked against the repository it came from. The first document + * keeps its primary diagram; the others' diagrams stay, not primary. + */ + +import * as path from 'node:path'; +import { REPO_ID, sealReviewDoc, withIds, type DocBlock, type DocRepo, type DocSection, type PinResolver, type ReviewDoc, type SectionKind } from './doc.js'; + +export interface AlsoSpec { + /** The other checkout, as given. */ + dir: string; + /** `--also =`: review it against this base instead of the document's own. */ + base: string | null; +} + +/** `../client` or `../client=origin/main`. */ +export function parseAlso(spec: string): AlsoSpec { + const eq = spec.lastIndexOf('='); + if (eq > 0) return { dir: spec.slice(0, eq), base: spec.slice(eq + 1) || null }; + return { dir: spec, base: null }; +} + +/** A short id for a repository: the remote's last segment or the directory name, made link-safe and unique. */ +export function repoId(name: string | null, dir: string, taken: ReadonlySet): string { + const raw = (name?.split('/').pop() || path.basename(path.resolve(dir)) || 'repo').toLowerCase(); + let id = raw.replace(/[^a-z0-9_.-]+/g, '-').replace(/^[^a-z0-9]+/, '').slice(0, 32) || 'repo'; + if (!REPO_ID.test(id)) id = 'repo'; + let out = id; + for (let n = 2; taken.has(out); n += 1) out = `${id}-${n}`; + return out; +} + +const LINK = /\]\((base|head)(@[a-z0-9][a-z0-9_.-]*)?:/g; + +/** A copy of a block with every pin, and every pin link in its text, naming `repo`. */ +export function tagBlock(block: DocBlock, repo: string): DocBlock { + const walk = (v: unknown, key?: string): unknown => { + if (typeof v === 'string') return key === 'text' ? v.replace(LINK, (_m, side: string) => `](${side}@${repo}:`) : v; + if (Array.isArray(v)) return v.map((x) => walk(x)); + if (v && typeof v === 'object') { + const o = v as Record; + const isPin = (o.side === 'base' || o.side === 'head') && typeof o.path === 'string' && typeof o.start === 'number'; + const out: Record = {}; + for (const [k, x] of Object.entries(o)) { + if (k === 'id') continue; // ids are re-derived after the merge + out[k] = walk(x, k); + } + if (isPin) out.repo = repo; + return out; + } + return v; + }; + const tagged = walk(block) as DocBlock; + if ('primary' in tagged && (tagged as { primary?: boolean }).primary) (tagged as { primary?: boolean }).primary = false; + return tagged; +} + +export interface OtherDoc { + id: string; + name: string | null; + doc: ReviewDoc; +} + +function heading(o: OtherDoc): DocBlock { + return { type: 'markdown', text: `**In ${o.name ?? o.id}** (\`${o.id}\`)` }; +} + +/** + * The first document, with the others folded in, re-sealed. Notes from the + * others are kept, prefixed with their repository. + */ +export function mergeDocuments(primary: ReviewDoc, others: OtherDoc[]): ReviewDoc { + if (others.length === 0) return primary; + const sections = new Map(); + const titles = new Map(); + for (const s of primary.sections) { + sections.set(s.kind, s.blocks.map((b) => ({ ...b }))); + titles.set(s.kind, s.title); + } + for (const o of others) { + for (const s of o.doc.sections) { + const blocks = s.blocks.map((b) => tagBlock(b, o.id)); + if (blocks.length === 0) continue; + const into = sections.get(s.kind) ?? []; + into.push(heading(o), ...blocks); + sections.set(s.kind, into); + } + } + const order: SectionKind[] = ['what_why', 'requirements', 'design', 'implementation']; + // Ids are unique across the document, not per section: assign them in one pass. + const kinds = order.filter((k) => (sections.get(k)?.length ?? 0) > 0); + const flat = withIds(kinds.flatMap((k) => (sections.get(k) ?? []).map(({ id: _id, ...b }) => b as DocBlock))); + let at = 0; + const merged: DocSection[] = kinds.map((k) => { + const n = sections.get(k)?.length ?? 0; + const blocks = flat.slice(at, at + n); + at += n; + return { kind: k, ...(titles.get(k) ? { title: titles.get(k) } : {}), blocks }; + }); + // A design section that only the others filled has no primary diagram: promote its first diagram. + const design = merged.find((s) => s.kind === 'design'); + if (design && !design.blocks.some((b) => (b as { primary?: boolean }).primary)) { + const first = design.blocks.find((b) => ['flow', 'sequence', 'call_stack_diff', 'data_store', 'system_map', 'code_peek'].includes(b.type)); + if (first) (first as { primary?: boolean }).primary = true; + } + const repos: DocRepo[] = others.map((o) => ({ + id: o.id, + name: o.name, + repo_key: o.doc.target.repo_key ?? '', + base_sha: o.doc.target.base_sha, + head_sha: o.doc.target.head_sha, + merge_base: o.doc.target.merge_base, + dirty_tree_hash: o.doc.target.dirty_tree_hash, + })); + const notes = [...primary.generator.notes, ...others.flatMap((o) => o.doc.generator.notes.map((n) => `${o.id}: ${n}`))]; + const by = [primary, ...others.map((o) => o.doc)].some((d) => d.generator.by !== 'vg') ? 'mixed' : 'vg'; + const { digest: _drop, ...body } = primary; + return sealReviewDoc({ + ...body, + title: `${primary.title} + ${others.map((o) => o.id).join(', ')}`.slice(0, 300), + sections: merged, + repos, + generator: { by, notes }, + }); +} + +/** One resolver for the merged document: a pin's `repo` picks the repository it is read from. */ +export function multiResolver(primary: PinResolver, others: ReadonlyMap): PinResolver { + return (side, p, repo) => (repo ? (others.get(repo)?.(side, p) ?? null) : primary(side, p)); +} diff --git a/src/review/provenance-cloud.test.ts b/src/review/provenance-cloud.test.ts new file mode 100644 index 0000000..ad21f73 --- /dev/null +++ b/src/review/provenance-cloud.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { newSession, saveSession, type StoredSession } from '../code/session-store.js'; +import { cloudSession, lookupProvenance, preparePush, pushProvenance, repoIdentity, trailerSessionIds } from './provenance-cloud.js'; +import type { ParsedDsn } from './push.js'; + +const dsn: ParsedDsn = { keyId: 'k', secret: 's', host: 'h.test', workspaceId: 'ws', scheme: 'https' }; +let dir: string; + +function git(...args: string[]): string { + const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8' }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return r.stdout; +} + +function session(id: string, tasks: Partial[]): StoredSession { + return { + ...newSession(id, 'anthropic', 'model-x', 1_790_000_000_000), + tasks: tasks.map((t) => ({ instruction: 'ask', summary: 'did', files: [], stopped: 'done', ts: 1_790_000_000_000, ...t })), + }; +} + +beforeEach(() => { + dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'vg-provc-'))); + git('init', '-q', '-b', 'main'); + git('config', 'user.email', 'dev@example.test'); + git('config', 'user.name', 'Dev'); + git('config', 'commit.gpgsign', 'false'); + git('config', 'remote.origin.url', 'https://github.com/acme/ledger.git'); + git('commit', '-q', '--allow-empty', '-m', 'init'); +}); + +afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + +describe('which sessions to share', () => { + it('reads trailers from the commits no remote has, or from ..HEAD', () => { + git('commit', '-q', '--allow-empty', '-m', 'one', '-m', 'Vibgrate-Session: sess_aaa111\nVibgrate-Session: sess_bbb222'); + git('commit', '-q', '--allow-empty', '-m', 'two', '-m', 'Vibgrate-Session: sess_aaa111'); + expect(trailerSessionIds(dir, undefined).sort()).toEqual(['sess_aaa111', 'sess_bbb222']); + expect(trailerSessionIds(dir, 'HEAD~1')).toEqual(['sess_aaa111']); + expect(() => trailerSessionIds(dir, 'no-such-ref')).toThrow(/could not list commits/); + }); + + it('keys the repository the way a review receipt does', () => { + expect(repoIdentity(dir)).toMatchObject({ name: 'acme/ledger', repo_key: expect.stringMatching(/^sha256:[0-9a-f]{64}$/) }); + }); +}); + +describe('what is sent', () => { + it('masks credentials, keeps repo-relative files only, and drops everything else', () => { + // Fake credentials, built at run time so the secret scanner never sees one in the source. + const key = ['API', 'KEY'].join('_') + '=' + 'abcdef' + '123456'; + const token = ['ghp', 'abcdefghijklmnop'].join('_'); + const s = session('sess_aaa111', [ + { instruction: `use ${key} and https://u:pw@db.test/x`, summary: `Wired ${token} in`, files: ['src/a.ts', '/elsewhere/b.ts', 'src/a.ts'] }, + ]); + s.tasks[0]!.attachments = [{ name: 'shot.png', kind: 'image' }]; + const c = cloudSession(s, dir); + if ('refused' in c) throw new Error(c.refused); + expect(c.turns).toEqual([ + { turn: 1, asked: 'use API_KEY:***redacted*** and https://u:***redacted***@db.test/x', answered: 'Wired ghp-***redacted*** in', files: ['src/a.ts'], ts: 1_790_000_000_000 }, + ]); + expect(JSON.stringify(c)).not.toMatch(/abcdef123456|pw@|abcdefghijklmnop|shot\.png/); + }); + + it('lists sessions not on this machine and does not send them', () => { + saveSession(dir, session('sess_aaa111', [{ files: ['src/a.ts'] }])); + const p = preparePush(dir, ['sess_aaa111', 'sess_gone99']); + expect(p.sessions.map((s) => s.id)).toEqual(['sess_aaa111']); + expect(p.missing).toEqual(['sess_gone99']); + }); +}); + +describe('Cloud calls', () => { + it('uploads in batches of 50 and reports what Cloud stored', async () => { + const bodies: { sessions: unknown[] }[] = []; + const fetchImpl = (async (_url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)) as { sessions: unknown[] }; + bodies.push(body); + return new Response(JSON.stringify({ status: 'ok', stored: body.sessions.length }), { status: 200 }); + }) as unknown as typeof fetch; + const one = cloudSession(session('sess_aaa111', [{}]), dir); + if ('refused' in one) throw new Error(one.refused); + const sessions = Array.from({ length: 51 }, (_, i) => ({ ...one, id: `sess_${String(i).padStart(6, '0')}` })); + const stored = await pushProvenance(dsn, { repo: repoIdentity(dir), sessions, missing: [], refused: [] }, fetchImpl); + expect(stored).toBe(51); + expect(bodies.map((b) => b.sessions.length)).toEqual([50, 1]); + expect(bodies[0]).toMatchObject({ kind: 'code_session_provenance', repo: { name: 'acme/ledger' } }); + }); + + it('says how to turn it on when the workspace has not opted in', async () => { + const fetchImpl = (async () => new Response(JSON.stringify({ code: 'provenance_upload_disabled', error: 'off' }), { status: 403 })) as unknown as typeof fetch; + await expect(lookupProvenance(dsn, repoIdentity(dir), ['sess_aaa111'], fetchImpl)).rejects.toThrow(/Agent provenance in Vibgrate Cloud settings/); + expect(await lookupProvenance(dsn, repoIdentity(dir), [], fetchImpl)).toEqual([]); + }); +}); diff --git a/src/review/provenance-cloud.ts b/src/review/provenance-cloud.ts new file mode 100644 index 0000000..504adde --- /dev/null +++ b/src/review/provenance-cloud.ts @@ -0,0 +1,156 @@ +/** + * Agent provenance in Vibgrate Cloud: share the VG Code sessions behind + * `Vibgrate-Session` trailers so a teammate's `vg why --cloud` + * can show what a session was asked, not just its id. + * + * Opt-in on both sides: `vg review trailer push` sends sessions only when run, + * and Cloud stores them only when a workspace admin has turned on Agent + * provenance. What is sent per turn is what `vg why` shows: the request, the + * agent's summary, the repo-relative files it changed and a timestamp. No file + * contents, diffs or attachments. Credential shapes are masked first, and a + * session that still carries one after masking is not sent. + */ + +import * as path from 'node:path'; +import { redactText, secretEgressRefusal } from '../code/secrets.js'; +import { loadSession, sessionTitle, type StoredSession } from '../code/session-store.js'; +import { CliError, ExitCode } from '../util/exit.js'; +import { VERSION } from '../version.js'; +import { defaultRun, normalizeRemote, repoKey, type GitRunner } from './git.js'; +import { postIngest, type ParsedDsn } from './push.js'; +import { TRAILER } from './provenance.js'; + +const SESSION_ID = /^[A-Za-z0-9_-]{6,64}$/; +const MAX_TEXT = 4000; +/** The mask `redactText` leaves in place of a value. */ +const REDACTED = '***redacted***'; +/** Sessions per upload; the API takes at most 50. */ +export const MAX_SESSIONS_PER_PUSH = 50; + +export interface CloudTurn { + turn: number; + asked: string; + answered: string; + files: string[]; + ts: number; +} + +export interface CloudSession { + id: string; + title: string; + provider: string | null; + model: string | null; + turns: CloudTurn[]; +} + +export interface RepoIdentity { + repo_key: string; + name: string; +} + +/** The repository's identity as Cloud keys it: the same repo key and name a review receipt uses. */ +export function repoIdentity(topLevel: string, run: GitRunner = defaultRun): RepoIdentity { + const raw = run(['config', '--get', 'remote.origin.url'], topLevel); + const remote = raw.status === 0 ? normalizeRemote(raw.stdout) : null; + return { repo_key: repoKey(remote, topLevel), name: remote ? remote.split('/').slice(-2).join('/') : path.basename(topLevel) }; +} + +/** + * Session ids named by trailers on the commits to share: `..HEAD` with + * `--base`, else the commits on HEAD that no remote branch has yet. + */ +export function trailerSessionIds(topLevel: string, base: string | undefined, run: GitRunner = defaultRun): string[] { + const range = base ? [`${base}..HEAD`] : ['HEAD', '--not', '--remotes']; + const res = run(['log', `--format=%(trailers:key=${TRAILER},valueonly,separator=%x2C)`, ...range, '--'], topLevel); + if (res.status !== 0) throw new CliError(`git could not list commits${base ? ` in ${base}..HEAD` : ''}`, ExitCode.USAGE_ERROR); + const ids = res.stdout.split(/[\n,]/).map((s) => s.trim()).filter((s) => SESSION_ID.test(s)); + return [...new Set(ids)]; +} + +/** A repo-relative path for a session file, or null when outside the repository. */ +function relTo(topLevel: string, sessionRoot: string, file: string): string | null { + const rel = path.relative(topLevel, path.resolve(sessionRoot, file)).split(path.sep).join('/'); + return !rel || rel.startsWith('..') || path.isAbsolute(rel) ? null : rel; +} + +const clip = (s: string) => (s.length > MAX_TEXT ? `${s.slice(0, MAX_TEXT - 1)}…` : s); + +/** + * What Cloud receives for one session: masked text, repo-relative files. + * Returns the reason when the session cannot be sent. + */ +export function cloudSession(s: StoredSession, topLevel: string): CloudSession | { refused: string } { + const root = s.worktree?.path ?? topLevel; + const turns = s.tasks.map((t, i) => ({ + turn: i + 1, + asked: clip(redactText(t.instruction ?? '')), + answered: clip(redactText(t.summary ?? '')), + files: [...new Set((t.files ?? []).map((f) => relTo(topLevel, root, f)).filter((f): f is string => f !== null))], + ts: t.ts, + })); + const out: CloudSession = { id: s.id, title: clip(redactText(sessionTitle(s))).slice(0, 300), provider: s.provider || null, model: s.model || null, turns }; + if (turns.length === 0) return { refused: 'it has no turns' }; + // A masked value still reads as an assignment (`API_KEY=***redacted***`), so + // the check runs on the text without the masks: what is left is unmasked. + const refusal = secretEgressRefusal(JSON.stringify(out).split(REDACTED).join(''), `session ${s.id}`); + return refusal ? { refused: refusal } : out; +} + +export interface PreparedPush { + repo: RepoIdentity; + sessions: CloudSession[]; + /** Named by a trailer but not on this machine. */ + missing: string[]; + /** On this machine but not sent, with why. */ + refused: { id: string; reason: string }[]; +} + +export function preparePush(topLevel: string, ids: string[], run: GitRunner = defaultRun): PreparedPush { + const out: PreparedPush = { repo: repoIdentity(topLevel, run), sessions: [], missing: [], refused: [] }; + for (const id of ids) { + const s = loadSession(topLevel, id); + if (!s) { + out.missing.push(id); + continue; + } + const c = cloudSession(s, topLevel); + if ('refused' in c) out.refused.push({ id, reason: c.refused }); + else out.sessions.push(c); + } + return out; +} + +function failure(res: { status: number; code?: string; detail?: string }): CliError { + if (res.code === 'provenance_upload_disabled') { + return new CliError( + 'this workspace does not accept agent provenance — a workspace admin can turn on Agent provenance in Vibgrate Cloud settings; nothing was uploaded', + ExitCode.ERROR, + ); + } + return new CliError(`Vibgrate Cloud answered ${res.status} — ${res.detail ?? ''}`, ExitCode.ERROR); +} + +/** Upload sessions, 50 at a time. Returns how many Cloud stored. */ +export async function pushProvenance(dsn: ParsedDsn, prepared: PreparedPush, fetchImpl: typeof fetch = fetch): Promise { + let stored = 0; + for (let i = 0; i < prepared.sessions.length; i += MAX_SESSIONS_PER_PUSH) { + const body = { kind: 'code_session_provenance', cli_version: VERSION, repo: prepared.repo, sessions: prepared.sessions.slice(i, i + MAX_SESSIONS_PER_PUSH) }; + const res = await postIngest(dsn, '/v1/ingest/provenance', body, fetchImpl); + if (!res.ok) throw failure(res); + stored += Number((res.json as { stored?: unknown } | undefined)?.stored ?? 0); + } + return stored; +} + +export interface CloudLookup extends CloudSession { + pushedAt: string; +} + +/** The sessions Cloud has for these ids in this repository. Ids never pushed are absent. */ +export async function lookupProvenance(dsn: ParsedDsn, repo: RepoIdentity, ids: string[], fetchImpl: typeof fetch = fetch): Promise { + if (ids.length === 0) return []; + const res = await postIngest(dsn, '/v1/ingest/provenance/lookup', { repo_key: repo.repo_key, session_ids: ids.slice(0, MAX_SESSIONS_PER_PUSH) }, fetchImpl); + if (!res.ok) throw failure(res); + const sessions = (res.json as { sessions?: unknown } | undefined)?.sessions; + return Array.isArray(sessions) ? (sessions as CloudLookup[]) : []; +} diff --git a/src/review/provenance.test.ts b/src/review/provenance.test.ts new file mode 100644 index 0000000..2f62a8d --- /dev/null +++ b/src/review/provenance.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { newSession, saveSession, type StoredSession } from '../code/session-store.js'; +import { addSessionTrailers, hookPath, HOOK_SCRIPT, lineProvenance, sessionsTouching, setTrailer, trailerState, turnsTouching } from './provenance.js'; + +const NOW = Date.parse('2026-10-02T10:00:00Z'); +let dir: string; + +function git(...args: string[]): string { + const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8' }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return r.stdout; +} + +function session(id: string, files: string[][], updatedAt = NOW): StoredSession { + const s = newSession(id, 'anthropic', 'model-x', updatedAt); + return { + ...s, + tasks: files.map((f, i) => ({ instruction: `ask ${i + 1}`, summary: `did ${i + 1}`, files: f, stopped: 'done', ts: updatedAt })), + }; +} + +beforeEach(() => { + dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'vg-prov-'))); + git('init', '-q', '-b', 'main'); + git('config', 'user.email', 'dev@example.test'); + git('config', 'user.name', 'Dev'); + git('config', 'commit.gpgsign', 'false'); + fs.writeFileSync(path.join(dir, '.gitignore'), '.vibgrate/\n'); + fs.mkdirSync(path.join(dir, 'src')); + fs.writeFileSync(path.join(dir, 'src/a.ts'), 'one\n'); + git('add', '.'); + git('commit', '-q', '-m', 'init'); +}); + +afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + +describe('the trailer hook', () => { + it('turns on and off, and never replaces another tool’s hook', () => { + expect(trailerState(dir)).toBe('off'); + expect(setTrailer(dir, true)).toBe('on'); + expect(fs.readFileSync(hookPath(dir), 'utf8')).toBe(HOOK_SCRIPT); + expect(setTrailer(dir, false)).toBe('off'); + expect(fs.existsSync(hookPath(dir))).toBe(false); + + fs.writeFileSync(hookPath(dir), '#!/bin/sh\necho theirs\n'); + expect(setTrailer(dir, true)).toBe('other-hook'); + expect(setTrailer(dir, false)).toBe('other-hook'); + expect(fs.readFileSync(hookPath(dir), 'utf8')).toContain('theirs'); + }); +}); + +describe('adding trailers', () => { + it('credits the recent sessions that touched a staged file, and no others', () => { + saveSession(dir, session('sess_touch1', [['src/a.ts']])); + saveSession(dir, session('sess_other1', [['src/b.ts']])); + saveSession(dir, session('sess_stale1', [['src/a.ts']], NOW - 30 * 86_400_000)); + expect(sessionsTouching(dir, ['src/a.ts'], NOW)).toEqual(['sess_touch1']); + // Without the History index the stored sessions are scanned. + fs.rmSync(path.join(dir, '.vibgrate/code-sessions/index.json')); + expect(sessionsTouching(dir, ['src/a.ts'], NOW)).toEqual(['sess_touch1']); + + fs.writeFileSync(path.join(dir, 'src/a.ts'), 'one\ntwo\n'); + git('add', 'src/a.ts'); + const msg = path.join(dir, 'MSG'); + fs.writeFileSync(msg, 'Add two\n'); + expect(addSessionTrailers(dir, msg, 'message', undefined, NOW)).toEqual(['sess_touch1']); + expect(fs.readFileSync(msg, 'utf8')).toBe('Add two\n\nVibgrate-Session: sess_touch1\n'); + // Running twice adds nothing new. + addSessionTrailers(dir, msg, 'message', undefined, NOW); + expect(fs.readFileSync(msg, 'utf8').match(/Vibgrate-Session/g)).toHaveLength(1); + }); + + it('leaves merge and squash messages alone, and never throws', () => { + saveSession(dir, session('sess_touch1', [['src/a.ts']])); + fs.writeFileSync(path.join(dir, 'src/a.ts'), 'changed\n'); + git('add', 'src/a.ts'); + const msg = path.join(dir, 'MSG'); + fs.writeFileSync(msg, 'Merge\n'); + expect(addSessionTrailers(dir, msg, 'merge', undefined, NOW)).toEqual([]); + expect(addSessionTrailers(dir, msg, 'squash', undefined, NOW)).toEqual([]); + expect(fs.readFileSync(msg, 'utf8')).toBe('Merge\n'); + expect(addSessionTrailers(path.join(dir, 'nowhere'), msg, 'message', undefined, NOW)).toEqual([]); + }); +}); + +describe('who wrote a line', () => { + it('finds the commit, its session, and the turns that touched the file', () => { + saveSession(dir, session('sess_touch1', [['src/b.ts'], ['src/a.ts']])); + fs.writeFileSync(path.join(dir, 'src/a.ts'), 'one\ntwo\n'); + git('add', 'src/a.ts'); + git('commit', '-q', '-m', 'Add two', '-m', 'Vibgrate-Session: sess_touch1'); + + const p = lineProvenance(dir, 'src/a.ts', 2); + expect(p.commit?.subject).toBe('Add two'); + expect(p.commit?.author).toBe('Dev'); + expect(p.sessions.map((s) => s.id)).toEqual(['sess_touch1']); + const found = p.sessions[0]!.found!; + expect(turnsTouching(found, dir, 'src/a.ts')).toEqual([{ turn: 2, asked: 'ask 2', answered: 'did 2' }]); + + // Line 1 came from a commit with no trailer. + expect(lineProvenance(dir, 'src/a.ts', 1).sessions).toEqual([]); + }); + + it('reports a session the trailer names but this machine does not have', () => { + fs.writeFileSync(path.join(dir, 'src/a.ts'), 'one\nthree\n'); + git('add', 'src/a.ts'); + git('commit', '-q', '-m', 'Add three', '-m', 'Vibgrate-Session: sess_elsewhere'); + expect(lineProvenance(dir, 'src/a.ts', 2).sessions).toEqual([{ id: 'sess_elsewhere', found: null }]); + }); + + it('has no commit for an uncommitted line', () => { + fs.writeFileSync(path.join(dir, 'src/a.ts'), 'one\nlocal\n'); + expect(lineProvenance(dir, 'src/a.ts', 2).commit).toBeNull(); + }); +}); diff --git a/src/review/provenance.ts b/src/review/provenance.ts new file mode 100644 index 0000000..c292adc --- /dev/null +++ b/src/review/provenance.ts @@ -0,0 +1,153 @@ +/** + * Agent provenance: which VG Code session wrote a commit, and so a line. + * + * Opt-in, per repository: `vg review trailer on` installs a git + * `prepare-commit-msg` hook. On each commit it finds the recent VG Code + * sessions that touched the staged files and adds a `Vibgrate-Session: ` + * trailer per session. The trailer carries only the session's random id: the + * session itself (what was asked, the agent's answers) stays in + * `.vibgrate/code-sessions/` on this machine and is never committed. + * + * `vg why ` then blames the line, reads the trailer and shows the + * session's requests that touched that file, marked as the agent's own + * account, unverified. + * + * The hook never blocks a commit: every failure is swallowed and the commit + * goes ahead without a trailer. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { loadSession, readSessionIndex, rebuildSessionIndex, sessionTitle, type StoredSession } from '../code/session-store.js'; +import { defaultRun, type GitRunner } from './git.js'; + +export const TRAILER = 'Vibgrate-Session'; +/** Sessions older than this are not credited with a commit. */ +export const SESSION_WINDOW_DAYS = 14; +const HOOK_MARK = '# vibgrate: Vibgrate-Session trailer'; +const SESSION_ID = /^[A-Za-z0-9_-]{6,64}$/; + +/** The hook script: one line that never fails the commit. */ +export const HOOK_SCRIPT = `#!/bin/sh +${HOOK_MARK} (remove with \`vg review trailer off\`) +command -v vg >/dev/null 2>&1 && vg review trailer --hook "$1" "$2" >/dev/null 2>&1 +exit 0 +`; + +/** Repo-relative path of a session's file, forward slashes, or null when outside the repository. */ +function relTo(topLevel: string, sessionRoot: string, file: string): string | null { + const rel = path.relative(topLevel, path.resolve(sessionRoot, file)).split(path.sep).join('/'); + return !rel || rel.startsWith('..') || path.isAbsolute(rel) ? null : rel; +} + +/** + * Sessions updated in the last `SESSION_WINDOW_DAYS` whose turns touched any + * of `files` (repo-relative), newest first. + */ +export function sessionsTouching(topLevel: string, files: string[], now: number = Date.now()): string[] { + const wanted = new Set(files.map((f) => f.split(path.sep).join('/'))); + const since = now - SESSION_WINDOW_DAYS * 86_400_000; + const entries = (readSessionIndex(topLevel) ?? rebuildSessionIndex(topLevel)).filter((e) => e.updatedAt >= since).sort((a, b) => b.updatedAt - a.updatedAt); + const out: string[] = []; + for (const e of entries) { + const s = loadSession(topLevel, e.id); + if (!s) continue; + const root = s.worktree?.path ?? topLevel; + const touched = s.tasks.some((t) => (t.files ?? []).some((f) => { + const rel = relTo(topLevel, root, f); + return rel !== null && wanted.has(rel); + })); + if (touched && SESSION_ID.test(s.id)) out.push(s.id); + } + return out; +} + +/** + * The hook's work: add a trailer for each session that touched the staged + * files. Skipped for merge and squash messages, which describe other commits. + * Returns the ids added (for tests); never throws. + */ +export function addSessionTrailers(topLevel: string, messageFile: string, source: string | undefined, run: GitRunner = defaultRun, now?: number): string[] { + try { + if (source === 'merge' || source === 'squash') return []; + const staged = run(['diff', '--cached', '--name-only', '-z'], topLevel); + if (staged.status !== 0) return []; + const files = staged.stdout.split('\0').filter(Boolean); + const ids = sessionsTouching(topLevel, files, now); + if (ids.length === 0) return []; + const args = ['interpret-trailers', '--in-place', '--if-exists', 'addIfDifferent']; + for (const id of ids) args.push('--trailer', `${TRAILER}: ${id}`); + args.push(messageFile); + return run(args, topLevel).status === 0 ? ids : []; + } catch { + return []; + } +} + +/** Where git looks for hooks here (honors core.hooksPath). */ +export function hookPath(topLevel: string, run: GitRunner = defaultRun): string { + const res = run(['rev-parse', '--git-path', 'hooks/prepare-commit-msg'], topLevel); + const p = res.status === 0 ? res.stdout.trim() : path.join('.git', 'hooks', 'prepare-commit-msg'); + return path.resolve(topLevel, p); +} + +export type TrailerState = 'on' | 'off' | 'other-hook'; + +export function trailerState(topLevel: string, run: GitRunner = defaultRun): TrailerState { + const p = hookPath(topLevel, run); + if (!fs.existsSync(p)) return 'off'; + return fs.readFileSync(p, 'utf8').includes(HOOK_MARK) ? 'on' : 'other-hook'; +} + +/** + * Turn the trailer on or off. A `prepare-commit-msg` hook that is not ours is + * never replaced or edited; the caller is told the one line to add to it. + */ +export function setTrailer(topLevel: string, on: boolean, run: GitRunner = defaultRun): TrailerState { + const p = hookPath(topLevel, run); + const state = trailerState(topLevel, run); + if (state === 'other-hook') return state; + if (on && state === 'off') { + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, HOOK_SCRIPT, { mode: 0o755 }); + return 'on'; + } + if (!on && state === 'on') { + fs.rmSync(p); + return 'off'; + } + return state; +} + +export interface LineProvenance { + commit: { sha: string; subject: string; author: string; date: string } | null; + /** Session ids the commit's trailers name. */ + sessions: { id: string; found: StoredSession | null }[]; +} + +/** Who wrote `file:line`: the commit that last changed it, and the sessions its trailers name. */ +export function lineProvenance(topLevel: string, file: string, line: number, run: GitRunner = defaultRun): LineProvenance { + const blame = run(['blame', '--porcelain', '-L', `${line},${line}`, '--', file], topLevel); + const sha = blame.status === 0 ? blame.stdout.split(/\s/)[0] : ''; + if (!/^[0-9a-f]{40,64}$/.test(sha) || /^0+$/.test(sha)) return { commit: null, sessions: [] }; + const show = run(['show', '-s', `--format=%s%x00%an%x00%cI%x00%(trailers:key=${TRAILER},valueonly,separator=%x2C)`, sha], topLevel); + if (show.status !== 0) return { commit: null, sessions: [] }; + const [subject, author, date, trailers] = show.stdout.replace(/\n$/, '').split('\0'); + const ids = [...new Set((trailers ?? '').split(',').map((s) => s.trim()).filter((s) => SESSION_ID.test(s)))]; + return { + commit: { sha, subject: subject ?? '', author: author ?? '', date: date ?? '' }, + sessions: ids.map((id) => ({ id, found: loadSession(topLevel, id) ?? null })), + }; +} + +/** The turns of a session that touched `file`, oldest first: what was asked, and the agent's own answer. */ +export function turnsTouching(s: StoredSession, topLevel: string, file: string): { turn: number; asked: string; answered: string }[] { + const root = s.worktree?.path ?? topLevel; + const want = file.split(path.sep).join('/'); + return s.tasks + .map((t, i) => ({ t, i })) + .filter(({ t }) => (t.files ?? []).some((f) => relTo(topLevel, root, f) === want)) + .map(({ t, i }) => ({ turn: i + 1, asked: t.instruction, answered: t.summary })); +} + +export { sessionTitle }; diff --git a/src/review/push.test.ts b/src/review/push.test.ts index 4906eba..b4db8ca 100644 --- a/src/review/push.test.ts +++ b/src/review/push.test.ts @@ -3,7 +3,8 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { CliError, ExitCode } from '../util/exit.js'; -import { buildEnvelope, collectSpans, pushReceipt, type ParsedDsn, type ReviewPushBody } from './push.js'; +import { buildEnvelope, collectSpans, pushReceipt, pushReviewDoc, reviewDocEnvelope, type ParsedDsn, type ReviewPushBody } from './push.js'; +import type { ReviewDoc } from './doc.js'; import type { CapsuleEvidence, ReviewReceipt } from './schemas.js'; import { capsule, finding, findings } from './test-fixtures.js'; @@ -153,3 +154,34 @@ describe('pushReceipt', () => { expect(url).toBe('http://localhost:8787/v1/ingest/review'); }); }); + +// ── pushReviewDoc ─────────────────────────────────────────────────────────── + +describe('pushReviewDoc', () => { + const docDsn: ParsedDsn = { keyId: 'k', secret: 's', host: 'us.ingest.vibgrate.com', workspaceId: 'ws_1', scheme: 'https' }; + const doc = { + schema_version: 'vg.review.doc.v1', + title: 'Review: a..b', + target: { repo_key: `sha256:${'a'.repeat(64)}`, base_sha: 'a'.repeat(40), head_sha: 'b'.repeat(40), merge_base: null, dirty_tree_hash: null }, + sections: [], + groups_digest: null, + generator: { by: 'vg', notes: [] }, + } as unknown as ReviewDoc; + + it('names the repository the way the receipt does, so the GitHub App finds both', () => { + const env = reviewDocEnvelope(doc, 'github.com/acme/ledger', '/work/ledger'); + expect(env).toMatchObject({ kind: 'review_doc', schema_version: 'vg.review.doc.v1', repo: { name: 'acme/ledger', remote: 'github.com/acme/ledger', repo_key: doc.target.repo_key } }); + expect(reviewDocEnvelope(doc, null, '/work/ledger').repo.name).toBe('ledger'); + }); + + it('posts to the review-doc route and surfaces the server reason when the workspace has not opted in', async () => { + const calls: string[] = []; + const fetchImpl = (async (url: string) => { + calls.push(url); + return new Response(JSON.stringify({ status: 'error', code: 'review_doc_upload_disabled', error: 'off' }), { status: 403 }); + }) as unknown as typeof fetch; + const res = await pushReviewDoc(docDsn, reviewDocEnvelope(doc, null, '/w'), fetchImpl); + expect(calls).toEqual(['https://us.ingest.vibgrate.com/v1/ingest/review-doc']); + expect(res).toMatchObject({ ok: false, status: 403, code: 'review_doc_upload_disabled', detail: 'off' }); + }); +}); diff --git a/src/review/push.ts b/src/review/push.ts index b30cab8..0046e3c 100644 --- a/src/review/push.ts +++ b/src/review/push.ts @@ -17,6 +17,7 @@ import { CliError, ExitCode } from '../util/exit.js'; import { VERSION } from '../version.js'; import type { AnalysisCapsule, ReviewIngestEnvelope, ReviewReceipt } from './schemas.js'; import { RECEIPT_SCHEMA } from './schemas.js'; +import { DOC_SCHEMA, type ReviewDoc } from './doc.js'; /** Snippet cap — a handful of lines per finding, never a file. */ const MAX_SNIPPET_LINES = 12; @@ -127,6 +128,10 @@ export interface PushResult { status: number; host: string; detail?: string; + /** The server's machine-readable reason, when it gave one. */ + code?: string; + /** The parsed JSON body of a successful response, when it had one. */ + json?: unknown; } /** @@ -138,7 +143,45 @@ export async function pushReceipt( body: ReviewPushBody, fetchImpl: typeof fetch = fetch, ): Promise { - const url = `${dsn.scheme}://${dsn.host}/v1/ingest/review`; + return postIngest(dsn, '/v1/ingest/review', body, fetchImpl); +} + +/** + * `vg review doc --push`: the whole review document, which carries + * code-derived text (signatures, conditions, table and field names, agent + * notes). Sent only when asked, and stored only when the workspace has turned + * review documents on in Vibgrate Cloud; otherwise the server refuses it with + * `code: "review_doc_upload_disabled"` and keeps nothing. + */ +export interface ReviewDocPushBody { + kind: 'review_doc'; + schema_version: typeof DOC_SCHEMA; + cli_version: string; + repo: { repo_key: string; name: string | null; remote: string | null }; + doc: ReviewDoc; +} + +export function reviewDocEnvelope(doc: ReviewDoc, remote: string | null, root: string): ReviewDocPushBody { + return { + kind: 'review_doc', + schema_version: DOC_SCHEMA, + cli_version: VERSION, + repo: { + repo_key: doc.target.repo_key ?? '', + // Same naming as the receipt, so the GitHub App finds both by `owner/repo`. + name: remote ? remote.split('/').slice(-2).join('/') : path.basename(root), + remote, + }, + doc, + }; +} + +export async function pushReviewDoc(dsn: ParsedDsn, body: ReviewDocPushBody, fetchImpl: typeof fetch = fetch): Promise { + return postIngest(dsn, '/v1/ingest/review-doc', body, fetchImpl); +} + +export async function postIngest(dsn: ParsedDsn, route: string, body: unknown, fetchImpl: typeof fetch): Promise { + const url = `${dsn.scheme}://${dsn.host}${route}`; const payload = JSON.stringify(body); let res: Response; try { @@ -160,7 +203,23 @@ export async function pushReceipt( } if (!res.ok) { const detail = await res.text().catch(() => ''); - return { ok: false, status: res.status, host: dsn.host, detail: detail.slice(0, 200) }; + let code: string | undefined; + let error: string | undefined; + try { + const parsed = JSON.parse(detail) as { code?: unknown; error?: unknown }; + if (typeof parsed.code === 'string') code = parsed.code; + if (typeof parsed.error === 'string') error = parsed.error; + } catch { + /* not JSON: keep the raw text */ + } + return { ok: false, status: res.status, host: dsn.host, detail: (error ?? detail).slice(0, 200), ...(code ? { code } : {}) }; + } + const text = await res.text().catch(() => ''); + let json: unknown; + try { + json = text ? JSON.parse(text) : undefined; + } catch { + json = undefined; } - return { ok: true, status: res.status, host: dsn.host }; + return { ok: true, status: res.status, host: dsn.host, ...(json !== undefined ? { json } : {}) }; } diff --git a/src/review/session.test.ts b/src/review/session.test.ts new file mode 100644 index 0000000..958897a --- /dev/null +++ b/src/review/session.test.ts @@ -0,0 +1,167 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { saveSession, type StoredSession } from '../code/session-store.js'; +import { buildReviewDoc, makePinResolver, markdownPinLinks, renderReviewDocMarkdown, validateReviewDoc } from './doc.js'; +import { collectChangeSet, type GitRunner } from './git.js'; +import { groupChangeSet } from './groups.js'; +import { resolveReviewSession, scopeChangeToSession, sessionBlocks, type ReviewSession } from './session.js'; + +/** + * "Show me what you did": a review document scoped to one VG Code session, + * against a real git repository and a session file shaped exactly as + * code/session-store.ts writes it. + */ + +const run: GitRunner = (args, cwd) => { + const res = spawnSync('git', args, { cwd, encoding: 'utf8' }); + return { stdout: res.stdout ?? '', status: res.status ?? 1 }; +}; +const git = (cwd: string, ...args: string[]) => { + const res = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (res.status !== 0) throw new Error(`git ${args.join(' ')}: ${res.stderr}`); + return res.stdout.trim(); +}; +const write = (root: string, rel: string, text: string) => { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), text); +}; +const lines = (n: number, tag: string) => Array.from({ length: n }, (_, i) => `export const ${tag}${i} = ${i};`).join('\n') + '\n'; + +function session(over: Partial = {}): StoredSession { + return { + id: 'sabc123', + provider: 'relay', + model: 'forge', + startedAt: 1, + updatedAt: 2, + tasks: [ + { instruction: 'Add input validation to the orders store', summary: 'Added validate().', files: ['src/a.ts'], stopped: 'finished', ts: 1 }, + { + instruction: 'Also log rejected orders — see [the spec](head:src/a.ts#L1-L2) and `logger`', + summary: 'I added logging in **b.ts** and updated a.ts.', + files: ['src/b.ts', 'src/a.ts', 'src/gone.ts'], + stopped: 'finished', + ts: 2, + }, + ], + lastChanges: [], + ...over, + }; +} + +describe('review document for a VG Code session', () => { + const roots: string[] = []; + afterEach(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); + }); + + function repo(): string { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'vg-review-session-'))); + roots.push(root); + git(root, 'init', '-q', '-b', 'main'); + git(root, 'config', 'user.email', 'test@example.com'); + git(root, 'config', 'user.name', 'Test'); + write(root, '.gitignore', '.vibgrate/\n'); + write(root, 'src/a.ts', lines(20, 'a')); + write(root, 'src/b.ts', lines(20, 'b')); + write(root, 'src/c.ts', lines(20, 'c')); + write(root, 'src/gone.ts', lines(3, 'g')); + git(root, 'add', '-A'); + git(root, 'commit', '-q', '-m', 'base'); + // The session's edits, plus one change it did not make. + write(root, 'src/a.ts', lines(20, 'a').replace('a3 = 3', 'a3 = validate(3)')); + write(root, 'src/b.ts', lines(20, 'b') + 'export const rejected = log();\n'); + write(root, 'src/c.ts', lines(20, 'c').replace('c1 = 1', 'c1 = 100')); + return root; + } + + it('resolves `latest` and an id, and names a missing session plainly', () => { + const root = repo(); + expect(resolveReviewSession(root, 'latest')).toEqual({ error: expect.stringMatching(/no VG Code session found/) }); + saveSession(root, session()); + const latest = resolveReviewSession(root, 'latest') as ReviewSession; + expect(latest.session.id).toBe('sabc123'); + expect(latest.root).toBe(root); + expect(latest.base).toBeNull(); + expect(resolveReviewSession(root, 'snope')).toEqual({ error: 'no VG Code session snope in .vibgrate/code-sessions' }); + }); + + it('keeps only the files the session touched, and says what was left out and what is gone', () => { + const root = repo(); + saveSession(root, session()); + const rs = resolveReviewSession(root, 'sabc123') as ReviewSession; + const scoped = scopeChangeToSession(collectChangeSet(root, undefined, run), rs); + expect(scoped.change.files.map((f) => f.path).sort()).toEqual(['src/a.ts', 'src/b.ts']); + expect(scoped.excluded).toEqual(['src/c.ts']); + expect(scoped.missing).toEqual(['src/gone.ts']); + }); + + it('turns each request into a pinned requirement, never a link the person typed', () => { + const root = repo(); + saveSession(root, session()); + const rs = resolveReviewSession(root, 'sabc123') as ReviewSession; + const change = collectChangeSet(root, undefined, run); + const scoped = scopeChangeToSession(change, rs); + const resolve = makePinResolver(scoped.change, {}, run); + const s = sessionBlocks(rs, scoped, resolve); + expect(s.title).toBe('What VG Code did: Add input validation to the orders store'); + const req = s.requirements[0] as { text: string }; + expect(req.text).toMatch(/\*\*Turn 1\*\* “Add input validation to the orders store” → `src\/a\.ts` \[L4\]\(head:src\/a\.ts#L4\)/); + expect(req.text).toMatch(/\*\*Turn 2\*\*/); + expect(req.text).toMatch(/`src\/gone\.ts` \(not in this change\)/); + // The only links are the ones vg made from the change, not the one inside the request. + const links = markdownPinLinks(req.text).map((l) => l.href); + expect(links).toEqual(['head:src/a.ts#L4', 'head:src/b.ts#L21', 'head:src/a.ts#L4']); + // The agent's words are quoted as its own account and marked unverified. + const account = s.what.find((b) => b.type === 'callout') as { text: string }; + expect(account.text).toMatch(/The agent's own account of turn 2.*not checked it against the code/s); + expect(account.text).toContain('\\*\\*b.ts\\*\\*'); + expect(s.notes.join(' ')).toMatch(/1 changed file the session did not touch was left out/); + expect(s.notes.join(' ')).toMatch(/1 file the session touched is not in this change.*src\/gone\.ts/); + }); + + it('builds a mixed-provenance document that validates and renders, requirements before implementation', () => { + const root = repo(); + saveSession(root, session({ tasks: [...session().tasks, { instruction: 'Now add tests', summary: '', files: [], stopped: 'max-steps', ts: 3 }] })); + const rs = resolveReviewSession(root, 'latest') as ReviewSession; + const scoped = scopeChangeToSession(collectChangeSet(root, undefined, run), rs); + const resolve = makePinResolver(scoped.change, {}, run); + const doc = buildReviewDoc({ change: scoped.change, groups: groupChangeSet(scoped.change), repoKey: null, resolve, session: sessionBlocks(rs, scoped, resolve) }); + expect(doc.sections.map((s) => s.kind)).toEqual(['what_why', 'requirements', 'implementation']); + expect(doc.generator.by).toBe('mixed'); + expect(doc.title).toMatch(/^What VG Code did: Add input validation/); + expect(validateReviewDoc(doc, resolve)).toEqual([]); + const md = renderReviewDocMarkdown(doc); + expect(md).toContain('1 turn did not finish: turn 3 stopped (max-steps)'); + expect(md).toContain('**Turn 3** “Now add tests” → no files changed'); + expect(md).not.toContain('src/c.ts'); + }); + + it('reads a worktree chat from its worktree, against the commit it branched from', () => { + const root = repo(); + git(root, 'stash', '-q', '-u'); + const base = git(root, 'rev-parse', 'HEAD'); + const wt = path.join(root, '.vibgrate', 'worktrees', 'wtabc1'); + git(root, 'worktree', 'add', '-q', '--detach', wt, base); + // A commit and an uncommitted edit, both inside the worktree. + write(wt, 'src/a.ts', lines(20, 'a').replace('a3 = 3', 'a3 = validate(3)')); + git(wt, 'commit', '-q', '-am', 'agent step'); + write(wt, 'src/b.ts', lines(20, 'b') + 'export const rejected = log();\n'); + saveSession(root, session({ worktree: { id: 'wtabc1', path: wt, base } })); + const rs = resolveReviewSession(root, 'latest') as ReviewSession; + expect(rs.root).toBe(wt); + expect(rs.base).toBe(base); + const change = collectChangeSet(rs.root, rs.base!, run, { inPlace: true }); + const scoped = scopeChangeToSession(change, rs); + expect(scoped.change.files.map((f) => f.path).sort()).toEqual(['src/a.ts', 'src/b.ts']); + const resolve = makePinResolver(scoped.change, { base: rs.base!, inPlace: true }, run); + const doc = buildReviewDoc({ change: scoped.change, groups: groupChangeSet(scoped.change), repoKey: null, resolve, session: sessionBlocks(rs, scoped, resolve) }); + expect(validateReviewDoc(doc, resolve)).toEqual([]); + expect(JSON.stringify(doc.sections[0])).toContain('in worktree wtabc1'); + // The main checkout is untouched by any of this. + expect(fs.readFileSync(path.join(root, 'src/a.ts'), 'utf8')).not.toContain('validate'); + }); +}); diff --git a/src/review/session.ts b/src/review/session.ts new file mode 100644 index 0000000..c92b6f4 --- /dev/null +++ b/src/review/session.ts @@ -0,0 +1,216 @@ +/** + * "Show me what you did": a review document scoped to one VG Code session. + * + * VG Code already records, per chat, what was asked each turn, the files each + * turn touched, how the turn stopped, and the agent's closing answer + * (`.vibgrate/code-sessions/.json`, see code/session-store.ts). That is + * first-party provenance: no trace capture, no hook, nothing new written. + * This module reads it and does three things: + * + * 1. resolves which checkout the session edited (the main tree, or its own + * worktree and the commit that worktree branched from), + * 2. narrows the change set to the files the session touched, and says + * which touched files are no longer in the change and which changed + * files were left out, + * 3. turns each turn's request into a pinned requirement, and quotes the + * agent's last summary as the agent's own words, marked unverified. + * + * Nothing here judges the change; the document's evidence rule still applies + * to every pin, and a requirement only links lines that land. + */ + +import * as path from 'node:path'; +import { loadLatestSession, loadSession, sessionTitle, type StoredSession } from '../code/session-store.js'; +import type { ChangeSet, ChangedFile } from './git.js'; +import { MAX_HUNK_LINKS, mdEscape, pinLink, plural, type DocBlock, type PinResolver } from './doc.js'; + +/** How much of one request a requirement quotes; the session file keeps the rest. */ +const MAX_ASK_CHARS = 600; +/** How much of the agent's closing answer is quoted. */ +const MAX_SUMMARY_CHARS = 1500; +/** Turns listed as requirements, newest kept when there are more. */ +const MAX_TURNS = 30; +/** Touched files listed per turn. */ +const MAX_FILES_PER_TURN = 12; + +export interface ReviewSession { + session: StoredSession; + /** The directory the session's file paths are relative to (the worktree for a worktree chat). */ + root: string; + /** For a worktree chat: the commit the worktree branched from, which is the change's base. */ + base: string | null; +} + +/** Turn a `--session` value (`latest` or an id) into a stored session, or a reason it cannot be read. */ +export function resolveReviewSession(mainRoot: string, spec: string): ReviewSession | { error: string } { + const session = spec === 'latest' ? loadLatestSession(mainRoot) : loadSession(mainRoot, spec); + if (!session) { + return { + error: + spec === 'latest' + ? 'no VG Code session found in this repository — run `vg code` first' + : `no VG Code session ${spec} in .vibgrate/code-sessions`, + }; + } + if (session.worktree) return { session, root: session.worktree.path, base: session.worktree.base }; + return { session, root: mainRoot, base: null }; +} + +/** Every file the session touched, repo-relative to the change's top level, forward slashes. */ +export function sessionFiles(rs: ReviewSession, change: ChangeSet): Map { + const byFile = new Map(); + rs.session.tasks.forEach((t, i) => { + for (const f of t.files ?? []) { + const rel = toRepoPath(rs.root, change.topLevel, f); + if (!rel) continue; + const turns = byFile.get(rel) ?? []; + if (!turns.includes(i)) turns.push(i); + byFile.set(rel, turns); + } + }); + return byFile; +} + +function toRepoPath(sessionRoot: string, topLevel: string, file: string): string | null { + const rel = path.relative(topLevel, path.resolve(sessionRoot, file)).split(path.sep).join('/'); + return !rel || rel.startsWith('..') || path.isAbsolute(rel) ? null : rel; +} + +export interface ScopedChange { + change: ChangeSet; + /** Files the session touched that are not in the change any more (committed past the base, or reverted). */ + missing: string[]; + /** Changed files the session did not touch, left out of the document. */ + excluded: string[]; +} + +/** Keep only the files the session touched; never invents a file the change does not have. */ +export function scopeChangeToSession(change: ChangeSet, rs: ReviewSession): ScopedChange { + const touched = sessionFiles(rs, change); + const norm = (f: ChangedFile) => f.path.replace(/\\/g, '/'); + const kept = change.files.filter((f) => touched.has(norm(f))); + const present = new Set(kept.map(norm)); + return { + change: { ...change, files: kept }, + missing: [...touched.keys()].filter((p) => !present.has(p)).sort(), + excluded: change.files.filter((f) => !touched.has(norm(f))).map(norm).sort(), + }; +} + +/** One line, at most `max` characters, cut at a word boundary when there is one nearby. */ +function clip(text: string, max: number): string { + const t = text.replace(/\s+/g, ' ').trim(); + if (t.length <= max) return t; + const cut = t.slice(0, max - 1); + const space = cut.lastIndexOf(' '); + return `${(space > max * 0.6 ? cut.slice(0, space) : cut).replace(/[\s,;:.—-]+$/, '')}…`; +} + +/** The chat's own name, or its first request, without sessionTitle's hard 80-character cut. */ +function chatName(session: StoredSession): string { + return session.title?.trim() || (session.tasks.find((t) => t.instruction?.trim())?.instruction ?? sessionTitle(session)); +} + +/** A request quoted inline: escaped so nothing in it reads as a link, code span or emphasis. */ +function quote(text: string, max: number): string { + return `“${mdEscape(clip(text, max)).replace(/\(/g, '\\(').replace(/\)/g, '\\)')}”`; +} + +export interface SessionBlocks { + title: string; + /** Prepended to what and why. */ + what: DocBlock[]; + requirements: DocBlock[]; + notes: string[]; +} + +/** + * The session's part of the document. Requirements link only lines that land + * on the head side; a touched file with no hunk in the change is named, not + * linked. + */ +export function sessionBlocks(rs: ReviewSession, scoped: ScopedChange, resolve: PinResolver): SessionBlocks { + const { session } = rs; + const change = scoped.change; + const fileByPath = new Map(change.files.map((f) => [f.path.replace(/\\/g, '/'), f])); + const name = chatName(session); + const turns = session.tasks.map((t, i) => ({ t, i })).filter(({ t }) => (t.instruction ?? '').trim()); + const shown = turns.slice(-MAX_TURNS); + const notes: string[] = []; + + const what: DocBlock[] = []; + const first = turns[0]?.t.instruction; + const intro: string[] = [ + `**Why** — made by VG Code in the session “${mdEscape(clip(name, 100))}” (${plural(session.tasks.length, 'turn')}, ${mdEscape(session.provider)} · ${mdEscape(session.model)}${session.worktree ? `, in worktree ${mdEscape(session.worktree.id)}` : ''}).`, + ]; + if (first) intro.push('', `First request: ${quote(first, MAX_ASK_CHARS)}`); + what.push({ type: 'markdown', text: intro.join('\n') }); + + const unfinished = session.tasks + .map((t, i) => ({ t, i })) + .filter(({ t }) => t.stopped && t.stopped !== 'finished' && t.stopped !== 'compacted'); + if (unfinished.length > 0) { + what.push({ + type: 'callout', + tone: 'warning', + text: `${plural(unfinished.length, 'turn')} did not finish: ${unfinished + .map(({ t, i }) => `turn ${i + 1} stopped (${mdEscape(t.stopped)})`) + .join(', ')}. What it changed is below; what it meant to do next is not.`, + }); + } + + const last = [...session.tasks].reverse().find((t) => (t.summary ?? '').trim()); + if (last) { + what.push({ + type: 'callout', + tone: 'note', + text: `**The agent's own account of turn ${session.tasks.indexOf(last) + 1}** — written by the model and quoted as it was written; vg has not checked it against the code. The pinned lines below are what changed.\n\n${quote(last.summary, MAX_SUMMARY_CHARS)}`, + }); + } + + const lines: string[] = [ + `What was asked, turn by turn, and the changed lines each turn touched${turns.length > shown.length ? ` (the last ${shown.length} of ${turns.length} turns)` : ''}.`, + '', + ]; + let linked = 0; + for (const { t, i } of shown) { + const files = [...new Set((t.files ?? []).map((f) => toRepoPath(rs.root, change.topLevel, f)).filter((p): p is string => !!p))]; + const parts: string[] = []; + for (const p of files.slice(0, MAX_FILES_PER_TURN)) { + const file = fileByPath.get(p); + if (!file) { + parts.push(`\`${mdEscape(p)}\` (not in this change)`); + continue; + } + if (file.op === 'removed') { + parts.push(`\`${mdEscape(p)}\` removed`); + continue; + } + const n = resolve('head', p); + const links = file.hunks + .filter((h) => n !== null && h.end <= n) + .slice(0, MAX_HUNK_LINKS) + .map((h) => `[L${h.start}${h.end !== h.start ? `–${h.end}` : ''}](${pinLink({ side: 'head', path: p, start: h.start, end: h.end })})`); + linked += links.length; + parts.push(`\`${mdEscape(p)}\`${links.length ? ` ${links.join(' · ')}` : ''}`); + } + const more = files.length - MAX_FILES_PER_TURN; + if (more > 0) parts.push(`+${more} more`); + lines.push(`- **Turn ${i + 1}** ${quote(t.instruction, MAX_ASK_CHARS)}${parts.length ? ` → ${parts.join(', ')}` : ' → no files changed'}`); + } + const requirements: DocBlock[] = shown.length > 0 ? [{ type: 'markdown', text: lines.join('\n') }] : []; + + notes.push( + `requirements quote VG Code session ${session.id} from .vibgrate/code-sessions; the requests are the person's, the summary is the agent's and is not verified`, + ); + if (linked === 0 && change.files.length > 0) notes.push('no requirement could link a changed line'); + if (scoped.missing.length > 0) { + notes.push( + `${plural(scoped.missing.length, 'file')} the session touched ${scoped.missing.length === 1 ? 'is' : 'are'} not in this change (committed past the base, or reverted): ${scoped.missing.slice(0, 8).join(', ')}${scoped.missing.length > 8 ? ', …' : ''} — pass --base to include commits`, + ); + } + if (scoped.excluded.length > 0) { + notes.push(`${plural(scoped.excluded.length, 'changed file')} the session did not touch ${scoped.excluded.length === 1 ? 'was' : 'were'} left out`); + } + return { title: `What VG Code did: ${clip(name, 72)}`, what, requirements, notes }; +} diff --git a/src/schema.ts b/src/schema.ts index 1386b1c..3c7310b 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -214,6 +214,18 @@ export interface GraphEdge { epistemic?: EpistemicTier; // coarse honesty tier derived from resolution+kind surprise?: number; // 0..1 improbability under the area model (`vg oddities`) count?: number; // call-site multiplicity + /** + * 1-based call-site lines in the caller's file, sorted, at most 8 (the + * smallest). `call` edges only; absent when the resolver had no position. + * Additive in vg-graph/1.1. See engine/edge-sites.ts. + */ + sites?: number[]; + /** + * True when at least one resolved call site awaits the call (`await f()`). + * `call` edges only; absent means no awaited site was seen, not "sync". + * Additive in vg-graph/1.1. + */ + awaited?: boolean; } export interface Area { diff --git a/src/version.ts b/src/version.ts index 045ac4e..924f035 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Calendar version (YYYY.DDD.PATCH), shared scheme with @vibgrate/cli. -export const VERSION = '2026.930.1'; +export const VERSION = '2026.1003.1'; diff --git a/test/edge-sites.test.ts b/test/edge-sites.test.ts new file mode 100644 index 0000000..5b2f6e0 --- /dev/null +++ b/test/edge-sites.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { buildGraph } from '../src/engine/build.js'; +import { addEdgeSite, MAX_EDGE_SITES } from '../src/engine/edge-sites.js'; +import { makeProject, cleanup } from './helpers.js'; +import type { GraphEdge, VgGraph } from '../src/schema.js'; + +/** + * Call-site lines on `call` edges: what a review's call-stack frame pins its + * call site to. Every resolver rung records them; the stored set depends only + * on which lines exist, never on visit order. + */ + +const PIN = '2020-01-01T00:00:00.000Z'; +const dirs: string[] = []; +afterEach(() => { + while (dirs.length) cleanup(dirs.pop()!); +}); + +function edgeByQn(graph: VgGraph, srcQn: string, dstQn: string): GraphEdge | undefined { + const qn = new Map(graph.nodes.map((n) => [n.id, n.qualifiedName])); + return graph.edges.find((e) => e.kind === 'call' && qn.get(e.src) === srcQn && qn.get(e.dst) === dstQn); +} + +const edge = (kind: GraphEdge['kind'] = 'call'): GraphEdge => ({ id: 'e', kind, src: 'a', dst: 'b', resolution: 'heuristic', confidence: 1 }); + +describe('addEdgeSite', () => { + it('keeps the smallest lines, sorted and unique, whatever the order', () => { + const lines = [40, 3, 17, 3, 99, 1, 55, 23, 8, 61, 12]; + const a = edge(); + const b = edge(); + for (const l of lines) addEdgeSite(a, l); + for (const l of [...lines].reverse()) addEdgeSite(b, l); + expect(a.sites).toEqual([1, 3, 8, 12, 17, 23, 40, 55]); + expect(a.sites).toHaveLength(MAX_EDGE_SITES); + expect(b.sites).toEqual(a.sites); + }); + + it('ignores non-call edges and invalid lines', () => { + const imp = edge('import'); + addEdgeSite(imp, 4); + expect(imp.sites).toBeUndefined(); + const call = edge(); + addEdgeSite(call, 0); + addEdgeSite(call, 2.5); + expect(call.sites).toBeUndefined(); + }); +}); + +describe('resolvers record call-site lines', () => { + const files = { + 'src/math.ts': 'export function add(a: number, b: number) {\n return a + b;\n}\n', + 'src/use.ts': "import { add } from './math';\nexport function calc() {\n const x = add(1, 2);\n return add(x, 3);\n}\n", + }; + + it('the precise TypeScript rung', async () => { + const root = makeProject(files); + dirs.push(root); + const { graph } = await buildGraph({ root, generatedAt: PIN, inline: true }); + const e = edgeByQn(graph, 'calc', 'add'); + expect(e?.resolution).toBe('tsc'); + expect(e?.sites).toEqual([3, 4]); + }); + + it('the heuristic rung', async () => { + const root = makeProject(files); + dirs.push(root); + const { graph } = await buildGraph({ root, generatedAt: PIN, inline: true, noTsc: true }); + const e = edgeByQn(graph, 'calc', 'add'); + expect(e?.resolution).toBe('heuristic'); + expect(e?.sites).toEqual([3, 4]); + }); +}); + +describe('resolvers record awaited calls', () => { + const files = { + 'src/io.ts': 'export async function load() {\n return 1;\n}\nexport function sync() {\n return 2;\n}\n', + 'src/use.ts': + "import { load, sync } from './io';\nexport async function run() {\n const a = await load();\n return a + sync();\n}\n", + }; + + for (const [rung, opts] of [ + ['the precise TypeScript rung', {}], + ['the heuristic rung', { noTsc: true }], + ] as const) { + it(rung, async () => { + const root = makeProject(files); + dirs.push(root); + const { graph } = await buildGraph({ root, generatedAt: PIN, inline: true, ...opts }); + expect(edgeByQn(graph, 'run', 'load')?.awaited).toBe(true); + expect(edgeByQn(graph, 'run', 'sync')?.awaited).toBeUndefined(); + }); + } +}); diff --git a/test/path-calls.test.ts b/test/path-calls.test.ts new file mode 100644 index 0000000..db87b24 --- /dev/null +++ b/test/path-calls.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { buildGraph } from '../src/engine/build.js'; +import { callPath, describeHops, shortestPath } from '../src/engine/paths.js'; +import { makeProject, cleanup } from './helpers.js'; +import type { VgGraph } from '../src/schema.js'; + +/** + * `vg path --calls` / `find_path calls_only`: follow call edges only, and say + * how each hop is joined (kind, resolver, call-site line, awaited). + */ + +const PIN = '2020-01-01T00:00:00.000Z'; +const dirs: string[] = []; +afterEach(() => { + while (dirs.length) cleanup(dirs.pop()!); +}); + +const files = { + 'src/db.ts': 'export async function insert(x: number) {\n return x;\n}\n', + 'src/service.ts': + "import { insert } from './db';\nexport async function place(x: number) {\n return await insert(x);\n}\n", + 'src/api.ts': "import { place } from './service';\nexport async function handler() {\n // entry\n return place(1);\n}\n", +}; + +async function graph(): Promise { + const root = makeProject(files); + dirs.push(root); + return (await buildGraph({ root, generatedAt: PIN, inline: true })).graph; +} + +const id = (g: VgGraph, qn: string) => g.nodes.find((n) => n.qualifiedName === qn)!.id; +const names = (g: VgGraph, ids: string[]) => ids.map((i) => g.nodes.find((n) => n.id === i)!.qualifiedName); + +describe('callPath', () => { + it('follows calls, with the call-site line and awaited flag per hop', async () => { + const g = await graph(); + const p = callPath(g, id(g, 'handler'), id(g, 'insert'))!; + expect(p.direction).toBe('forward'); + expect(names(g, p.ids)).toEqual(['handler', 'place', 'insert']); + const hops = describeHops(g, p.ids, p.direction); + expect(hops.map((h) => [h.kind, h.line, h.file])).toEqual([ + ['call', 4, 'src/api.ts'], + ['call', 3, 'src/service.ts'], + ]); + expect(hops[1].awaited).toBe(true); + expect(hops[0].awaited).toBeUndefined(); + }); + + it('answers in reverse when the call arrow points the other way', async () => { + const g = await graph(); + const p = callPath(g, id(g, 'insert'), id(g, 'handler'))!; + expect(p.direction).toBe('reverse'); + expect(names(g, p.ids)).toEqual(['insert', 'place', 'handler']); + }); + + it('never crosses an import or contains edge, unlike the plain shortest path', async () => { + const g = await graph(); + const file = (rel: string) => g.nodes.find((n) => n.kind === 'file' && n.file === rel)!.id; + // api.ts reaches db.ts only through imports. + const plain = shortestPath(g, file('src/api.ts'), file('src/db.ts'))!; + expect(describeHops(g, plain.ids, plain.direction).every((h) => h.kind === 'import')).toBe(true); + expect(callPath(g, file('src/api.ts'), file('src/db.ts'))).toBeNull(); + }); +});