lody 0.57.1-next.2 → 0.57.1-next.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ const reviewPrompt = "# Code Review Helper Agent Prompt\n\nYou are preparing a local `*.review.md` file for Code Review Helper. Your job is to\norganize the current PR into reviewable logic groups and explain each one well enough\nthat a human can judge it \u2014 not to perform an exhaustive line-by-line audit.\n\n## North Star: let a busy reader understand the change without reading every line\n\nYour reader is a professional programmer, but in practice they review in \"vibe coding\"\nmode \u2014 they do **not** have time to read the implementation line by line. They care\nabout the **core logic, the architecture, and whether the implementation does what it\nshould**. Write so they can understand and judge the change without reading every line.\n\nCode can only express **how** something is done. It cannot express:\n\n- **Why** the change was made, and the **motivation** behind it.\n- The **requirements and decision points** \u2014 and whether any requirements conflicted.\n- The overall **order of operations**, the **data/control flow**, and the **lifecycle**.\n- The **architecture** and how pieces fit together.\n\nThese are exactly what the reviewer needs and exactly what you must supply \u2014 synthesized\nfrom commit messages, code comments, the PR description, and any context docs\n(`AGENTS.md`, `specs/`, `docs/`). Put them in the overall summary and the group\ndescriptions.\n\nAim for a **clear, easy-to-understand** explanation. This is a trade-off, not an\nabsolute rule: do not mechanically restate what the code already makes obvious, but do\nexplain freely whenever it helps the reader understand. **Clarity beats brevity** \u2014\nlength is fine when it earns its keep; padding is not.\n\nDo **not** prescribe _how_ the reviewer should read \u2014 no effort labels\n(Skim/Review/Scrutinize), no \"bottom line / how to read / if you check one thing\" lines,\nno step-by-step reading path. Everyone reads differently. You convey importance through\n**group order** (most important first) and through the description itself, not through\nreading instructions.\n\nYou may flag bugs you notice as `P0`/`P1`/`P2` notes, but do not turn this into an\nexhaustive bug hunt; making the change understandable comes first.\n\n## Language\n\nBefore producing the `.review.md` file, confirm the review request with the user. Infer\nthe language the review should be written in from how the **user** speaks in the\nconversation (not from the programming language of the code). If it is ambiguous, ask.\nWrite the **entire** review \u2014 headings and prose \u2014 in that language; the English labels\nin the templates below are only placeholders.\n\n## Inputs\n\n- A Git workspace.\n- A target branch, PR, commit, commit range, or merge base ref, if provided by the user.\n- Current checkout represents the PR head only when the user did not specify another\n target.\n\n## Required Git Facts\n\n1. Check whether the workspace is dirty before resolving the review diff:\n `git status --short`\n If output is non-empty, stop before writing the `.review.md` and ask the user whether\n to first commit the dirty changes or review only the already-committed non-dirty diff.\n Do not include uncommitted changes by default; this format compares two commits.\n2. Resolve the review target. Honor user-provided target selectors first:\n - base branch/ref;\n - PR number or URL;\n - branch name;\n - single commit;\n - explicit `<base>..<head>` or `<base>...<head>` range.\n3. Determine `current_commit`:\n - For a PR target, FETCH the PR branch locally first (e.g. `gh pr checkout <number>`\n or `git fetch <remote> <ref>`), then use its head commit. `lody review` renders by\n reading `merge_base` and `current_commit` from LOCAL Git, so both commits must\n exist locally \u2014 getting only the SHA from GitHub tooling is not enough.\n - For a branch target, use that branch tip.\n - For a single commit target, use that commit.\n - Otherwise use the current checkout:\n `git rev-parse HEAD`\n4. Determine `merge_base`:\n - Prefer the user-provided base ref.\n - For PR targets, use the PR base ref.\n - For a single commit without an explicit base, use the commit's first parent.\n - For double-dot ranges, use the left side as the base commit input.\n - For triple-dot ranges, use `git merge-base <base> <head>`.\n - Otherwise use the repository's PR/base branch convention:\n `git merge-base <base-ref> <current_commit>`\n5. Inspect commits:\n `git log --oneline --decorate <merge_base>..<current_commit>`\n6. Inspect changed files and line counts:\n `git diff --name-status -M <merge_base> <current_commit>`\n `git diff --numstat -M <merge_base> <current_commit>`\n\n## Context Gathering (do this before grouping)\n\nThe goal is to make each group easy for a human to understand. Spend the time needed \u2014\ntaking longer here is expected and worth it:\n\n1. **PR metadata** \u2014 If the target is a PR, read its title and description. Use the PR\n title as the review document's level-1 title unless it is clearly wrong, and fold the\n PR description into the overall summary. Include `pr_number`, `pr_url`, `pr_title` in\n the frontmatter.\n2. **Commit history** \u2014 Read commit messages in `<merge_base>..<current_commit>` for the\n sequence of changes and any stated intent or caveats. Map each commit to its group(s).\n3. **Repository context** \u2014 Read related `AGENTS.md` / `context/` / `specs/` / `docs/`\n files for the changed modules, and code comments, to recover the _why_, invariants,\n and decisions the diff alone can't show. (These may not exist \u2014 use what's available.)\n4. **Surrounding code** \u2014 Read the modules the change interacts with, even unchanged\n ones, to understand call sites, contracts, and execution flow.\n5. **Delegate deep investigation to sub-agents.** When understanding the change needs\n work that fans out \u2014 tracing all call sites, verifying an invariant or\n backward-compatibility across modules, checking which APIs/behaviors changed,\n confirming every site of a repeated change was updated, or running greps/searches \u2014\n spawn sub-agents to investigate in parallel and fold their conclusions into the\n summary, group descriptions, and notes. Prefer spending more time here for a more\n reliable review.\n\n## Output File\n\nWrite exactly one Markdown file named with the suffix `.review.md`.\n\nWrite it to a temporary directory \u2014 your system temp dir (`$TMPDIR` / `/tmp` on\nmacOS/Linux, `%TEMP%` on Windows), e.g. `/tmp/<name>.review.md` \u2014 NOT inside the\nreviewed repository, so it is never accidentally committed. Tell the user the absolute\npath you wrote it to. (`lody review` resolves line references against the repo via\n`--repo`/the current dir, so the `.review.md` itself can live anywhere.)\n\nIt must start with this frontmatter:\n\n```yaml\n---\nreview_version: 1\nmerge_base: <full merge base sha>\ncurrent_commit: <full current commit sha>\nbase_ref: <optional base ref if known>\nline_budget: 1500\npr_number: <PR number>\npr_url: <PR URL>\npr_title: <optional PR title>\n---\n```\n\nInclude the `pr_number` / `pr_url` / `pr_title` fields only when the review target is a\npull request; omit them otherwise. Do not put YAML comment (`#`) lines in the\nfrontmatter \u2014 the validator flags them as unsupported.\n\n`line_budget` is a **soft guideline** for how large a single group should be (in changed\nlines), to keep groups focused. It is not a hard limit and not a limit on prose. There\nis **no limit** on the file length, a group's description length, or the **number of\ngroups** \u2014 a dozen or twenty groups is fine. Make groups as granular as the logic needs.\n\nImmediately after the closing `---`, write the overall review title as a single\nlevel-1 Markdown heading. Prefer the PR title; otherwise summarize the diff in one\nphrase:\n\n```md\n# Refactor adapter public surface and centralize label casing\n```\n\n### Overall summary \u2014 surface the risk story up front\n\nAfter the title, write the **overall summary** (everything before the first `## Group:`\nheading; rendered at the top of the review). Open with a sentence or two on what the PR\ndoes and why, then \u2014 **at a glance, up front** \u2014 call out whichever of these apply so the\nreviewer immediately sees the risk surface (omit the ones that don't apply; don't pad\nwith empty headings):\n\n- **Architecture / structural changes.**\n- **Invariants** \u2014 ones newly introduced, and ones changed or broken.\n- **System behavior changes** \u2014 user-visible or internal.\n- **External API changes.**\n- **Format / schema / protocol changes.**\n- **Backward/forward compatibility, migrations, and breaking changes.**\n\n```md\nOne or two sentences: what this PR does and why.\n\n- **Breaking:** `resolveSync` is removed; downstream packages must migrate to `resolve`.\n- **New invariant:** every local-transport file now requires a machine owner.\n- **Behavior:** empty input resolves to `\"untitled\"` instead of throwing.\n- Out of scope: storage quota (tracked separately).\n```\n\nIt is prose only \u2014 no `## Group:` headings, `changes://`/`context://` blocks, or notes.\nDo not write a reading order or priority ranking here either.\n\n## The `## Review` section \u2014 top findings\n\nAfter the overall summary, add a top-level `## Review` section listing the most\nimportant findings as `- <PRIORITY>:` bullets. This is the single home for `P0` / `P1`\n/ `P2` issues: the renderer pins them at the top and turns every code reference into a\nclickable chip that jumps the center diff to that exact location, so one finding can tie\ntogether several places across files.\n\n```md\n## Review\n\n- P0: <what is wrong and why it matters>. Explain it well enough to act on, and cite the\n exact code \u2014 it can span several spots: `new://flock-rs/src/file_v2.rs:L168-L181` and\n the call site `new://flock-rs/src/file_flock/v2_overlay.rs:L226-L236`.\n- P1: <a real risk or behavior change worth a second look>. `new://packages/wasm/ts-src/index.ts:L1232-L1242`\n- P2: <a minor issue or nit>. `new://flock-rs/flock-v2-codec/src/record_table.rs:L747-L753`\n```\n\nRules for findings:\n\n- **Keep them few \u2014 at most 8 total across P0 + P1 + P2.** Precision over recall: raise\n only what genuinely needs a human's attention; when unsure, leave it out. If there is\n nothing to flag, write `## Review` with no bullets (or omit the section).\n- **Clarity over brevity \u2014 there is no length limit on a finding.** Prioritize being\n understood: say what is wrong, why it matters, and what to check, so the reader can act\n without re-deriving it. Do not compress to a cryptic one-liner.\n- **Always reference the source.** Every finding must cite concrete code via one or more\n inline refs (below); a finding may list several locations/files.\n- One finding = one judgment. Order by priority (P0 first, then P1, then P2).\n\n### Finding code references\n\nInside a `## Review` finding, reference code with a backticked, path-qualified token:\n\n- `new://<path>:Lx-Ly` \u2014 current-commit version, lines x\u2013y (use `:Lx` for a single line).\n- `old://<path>:Lx-Ly` \u2014 merge-base version of the file.\n- `new://<path>` (no `:L`) \u2014 the whole file.\n- A bare `` `<path>` `` also renders as a chip when it names a changed file.\n\n`<path>` is repository-relative and must appear in some group's `changes://` block (so\nthe chip has a diff to jump to). The colon after the ref may be ASCII `:` or fullwidth\n`\uFF1A`.\n\n## Review Groups\n\nCreate one or more groups. A group is a `## Group:` heading, a `Commits:` line, and a\ndescription that goes as deep as the change deserves:\n\n```md\n## Group: Descriptive, logic-focused title that explains the group's purpose\n\nCommits: `abc123`, `def456`\n\nA description that makes the change understandable without reading every line: what it\ndoes and why, the data/control flow and execution path, the invariants and contracts\ninvolved, the before\u2192after behavior, and the architecture. Reference specific source\nlocations (e.g. `packages/foo/src/bar.ts:120`) to trace an event or a flow. Go long when\nthe change is important or subtle; stay short when it is simple. Use sub-headings and\nlists when they help.\n\n`changes://path/to/file.ts`\n\n- QUESTION `new://L60`: ask the author \u2014 something genuinely unclear about this line.\n- INFO `new://L78`: context the reviewer would otherwise have to confirm by hand.\n```\n\n- `Commits:` is required when the group has commits \u2014 list the short SHAs as inline code,\n comma-separated; the renderer turns them into commit cards. Put it directly under the\n heading.\n- Reference each changed file with a plain **`changes://path` (no line range)**. The\n renderer shows the whole file diff, collapses unchanged regions (the reviewer can\n expand them for surrounding context), and anchors your notes to their lines. Do **not**\n add `?old=...&new=...` ranges \u2014 they no longer crop the diff and are ignored; cropping\n would hide the rest of the file. Reference each changed file **once per group**\n (multiple `changes://<same file>` blocks in a group merge into one file view).\n- You may structure a long description with optional `### Flow` / `### Behavior change` /\n `### Invariants & contracts` / `### Completeness` sub-headings \u2014 include only those that\n carry real signal, and drop any you have nothing concrete to say under.\n\n### Inline notes (INFO / QUESTION)\n\nInside a group, a `changes://` or `context://` block may be followed by inline notes \u2014\nbut only for `INFO` context and `QUESTION`s about a **single spot**. They render inline\nat the line and bind to the nearest preceding block:\n\n- `QUESTION` \u2014 genuine uncertainty about this line; ask instead of inflating it to a P-level.\n- `INFO` (or no prefix) \u2014 neutral context the reviewer would otherwise confirm by hand.\n\n```md\n- QUESTION `new://L60`: why is this value hard-coded?\n- INFO `new://L78`: this call site consumes the changed API.\n```\n\nEach inline note is one judgment about one line \u2014 don't narrate the diff line by line.\n\n**Anything that rises to `P0` / `P1` / `P2`, or that spans more than one location,\nbelongs in the top `## Review` section instead \u2014 not as an inline note.** A note's\n`new://Lx` / `old://Lx` reference here carries no path (it inherits the preceding\nblock's file); the path-qualified form is only for `## Review` findings.\n\n## Grouping Rules\n\n- Group by product/code logic first, not mechanically by commit.\n- **Order groups by importance: most important / highest-risk first**, routine changes\n last. Put public API, concurrency, persistence/serialization, parsing, security,\n migration, cross-module, and user-visible behavior changes first; formatting, generated\n files, lockfiles, snapshot churn, and pure renames last. The render order is the\n reviewer's default path \u2014 but do not add explicit reading-order or effort labels.\n- Use descriptive `## Group:` titles so a reviewer can judge from the title (and\n description) what the group covers and how much it matters.\n- List the contributing commits in each group's `Commits:` line.\n- Keep groups semantically coherent; `line_budget` is a soft guideline, not a hard limit.\n- For a change repeated across many files (the same transform in N places), explain one\n representative file in depth and note the rest follow the same pattern \u2014 don't repeat\n the same notes N times.\n- Reference each changed file once per group with a plain `changes://path`; use\n `context://` blocks for unchanged files needed to understand the change.\n\n## Context Blocks (`context://`)\n\nWhen a reviewer needs to see an **unchanged** file (or an unchanged part of a changed\nfile) to understand the diff, reference it with a `context://` block:\n\n```md\n`context://path/to/unchanged.ts?range=L10-L30`\n```\n\n- `context://` blocks show the current commit's version of the file, not a diff.\n- Only the requested range plus a few lines of surrounding context are rendered.\n- Notes may still be attached using `new://Lx-Ly` references.\n- Use `context://` sparingly: only the ranges actually needed to understand the change.\n\n## Line Reference Rules\n\n- `changes://path` and `context://path` paths are repository-relative paths.\n- `old://` and `new://` notes bind to the nearest preceding `changes://` or\n `context://` block.\n- `old://Lx-Ly` refers to the merge-base version of that file (valid only for\n `changes://` blocks).\n- `new://Lx-Ly` refers to the current commit version of that file.\n- `context://` blocks only support `new://` references.\n- Only note lines (`- [PRIORITY] `new://Lx`: ...`) become clickable, cross-focusing\n markers. A `new://Lx` written inside prose is just text \u2014 if a point should be\n clickable from the diff, write it as a note.\n- Only write notes for line numbers you verified from source or diff output.\n- Do not use notes as a prose summary for every changed line.\n\n## Worked Example (one complete group)\n\n```md\n## Review\n\n- P0: `resolve()` is now `async`, but a caller that forgets `await` gets a Promise \u2014 which\n is always truthy \u2014 so existing `if (resolve(x))` checks pass silently and validation is\n skipped. The new async entry point is `new://src/adapter.ts:L92`, and the empty-input\n change that makes a missing guard dangerous is `new://src/adapter.ts:L104`.\n- P1: the unchanged validator still assumes `resolve` throws on empty input; after this\n change it never throws, so the guard at `old://src/path/validator.ts:L26` is now dead\n code that silently lets empty input through.\n\n## Group: Collapse the adapter's four public resolve methods into one async `resolve()`\n\nCommits: `abc123`, `def456`\n\nThis replaces `resolveSync` / `resolveAsync` / `tryResolve` / `resolveOr` with a single\n`resolve(input, opts)` and migrates all 12 call sites. The motivation is to stop the four\nmethods drifting apart \u2014 they had subtly different empty-input handling. The risky part is\nthe sync\u2192async shift, which can compile cleanly while behaving wrong.\n\n### Flow\n\n`resolve()` (`src/adapter.ts:88`) is now the single entry point; the three legacy names\nare thin wrappers that `await` it. Input normalization moved into `normalizeInput()`\n(`src/adapter.ts:104`), so empty / `\"..\"` handling is defined in exactly one place and\nshared by every caller.\n\n### Behavior change\n\n- Before: `resolveSync(\"\")` threw `InvalidPath`.\n- After: `resolve(\"\")` resolves to `\"untitled\"` \u2014 it never throws.\n- Callers that relied on the throw for validation silently lose that guard.\n\n`context://src/path/validator.ts?range=L20-L34`\n\n- INFO `new://L26`: this is the validator that assumed `resolve` throws (see the P1 above).\n\n`changes://src/adapter.ts`\n\n- INFO `new://L88`: the single new entry point; every other method routes here.\n- QUESTION `new://L120`: retry count dropped 5\u21921 here \u2014 intentional, or leftover debug?\n\n### Completeness\n\nAll 12 call sites are migrated (grep for `resolveSync|tryResolve|resolveOr` is clean).\nTests still cover only the success path; the new empty-input behavior is untested.\n```\n\n## Validation\n\nBefore handing off, run:\n\n```sh\nreview-helper validate <file.review.md> --repo <repo>\n```\n\nFix every error. Warnings are allowed only when intentional.\n\n## Hand-off / rendering\n\nAfter the file validates, render it into a self-contained HTML page and open it for\nthe user (no Lody login required):\n\n```sh\nnpx lody review <file.review.md>\n```\n\nConstraints:\n\n- Run this from inside the reviewed repository's Git working tree, or pass\n `--repo <dir>`. Line references resolve against that repo (read-only).\n- The commits the review references must exist locally \u2014 make sure the branch/PR is\n fetched before rendering.\n- It writes the HTML into your system temp dir (NOT the repo), prints the absolute\n path (`Rendered review \u2192 \u2026`), and opens it in the browser (`--no-open` to skip).\n Override the location with `--output <path>`. The HTML is a single self-contained\n file that works over `file://`.\n\nReport both paths to the user \u2014 the `.review.md` (in the temp dir) and the rendered\n`.review.html` (printed by `lody review`) \u2014 so they can re-open, move, or share them.\nNeither file should be committed to the repo.\n";
2
+ export {
3
+ reviewPrompt
4
+ };
@@ -0,0 +1,308 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import path__default from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { d as parseReviewMarkdown, v as validateParsedReviewDocument, f as validateResolvedBlock, a as countLines, b as createSparseTextForRanges } from "./sparse-text-D7zcV2O5.js";
6
+ const execFileAsync = promisify(execFile);
7
+ async function resolveReviewBundle(options) {
8
+ const reviewFilePath = path__default.resolve(options.reviewFilePath);
9
+ const repoPath = path__default.resolve(options.repoPath ?? process.cwd());
10
+ const markdown = await readFile(reviewFilePath, "utf8");
11
+ const document = parseReviewMarkdown(markdown, { sourcePath: reviewFilePath });
12
+ const diagnostics = validateParsedReviewDocument(document);
13
+ await validateCommit(repoPath, document.frontmatter.mergeBase, "merge_base", diagnostics);
14
+ await validateCommit(repoPath, document.frontmatter.currentCommit, "current_commit", diagnostics);
15
+ const statusEntries = await getNameStatus(
16
+ repoPath,
17
+ document.frontmatter.mergeBase,
18
+ document.frontmatter.currentCommit,
19
+ diagnostics
20
+ );
21
+ const statusByPath = buildStatusMap(statusEntries);
22
+ const numstatByPath = await getNumstat(
23
+ repoPath,
24
+ document.frontmatter.mergeBase,
25
+ document.frontmatter.currentCommit,
26
+ diagnostics
27
+ );
28
+ const referencedPaths = [
29
+ ...new Set(document.groups.flatMap((group) => group.blocks.map((block) => block.path)))
30
+ ];
31
+ const files = {};
32
+ for (const reviewPath of referencedPaths) {
33
+ files[reviewPath] = await resolveFile({
34
+ repoPath,
35
+ mergeBase: document.frontmatter.mergeBase,
36
+ currentCommit: document.frontmatter.currentCommit,
37
+ reviewPath,
38
+ status: statusByPath.get(reviewPath),
39
+ numstat: numstatByPath.get(reviewPath)
40
+ });
41
+ }
42
+ const groups = document.groups.map((group) => {
43
+ const blocks = group.blocks.map((block) => {
44
+ const file = files[block.path];
45
+ const rangeFiltered = block.oldRange !== void 0 || block.newRange !== void 0;
46
+ const displayOldText = file === void 0 ? "" : rangeFiltered ? sparseOrBlank(file.oldText, block.oldRange) : file.oldText;
47
+ const displayNewText = file === void 0 ? "" : rangeFiltered ? sparseOrBlank(file.newText, block.newRange) : file.newText;
48
+ const resolvedBlock = {
49
+ ...block,
50
+ ...file === void 0 ? {} : { file },
51
+ displayOldText,
52
+ displayNewText,
53
+ diagnostics: []
54
+ };
55
+ return {
56
+ ...resolvedBlock,
57
+ diagnostics: validateResolvedBlock(resolvedBlock)
58
+ };
59
+ });
60
+ return {
61
+ ...group,
62
+ blocks,
63
+ diagnostics: []
64
+ };
65
+ });
66
+ const commits = await resolveCommits(repoPath, document.groups, diagnostics);
67
+ return {
68
+ reviewFilePath,
69
+ repoPath,
70
+ document,
71
+ groups,
72
+ files,
73
+ commits,
74
+ diagnostics
75
+ };
76
+ }
77
+ const COMMIT_FIELD_SEPARATOR = "";
78
+ const COMMIT_FORMAT = ["%H", "%h", "%an", "%ae", "%aI", "%s", "%b"].join(COMMIT_FIELD_SEPARATOR);
79
+ async function resolveCommits(repoPath, groups, diagnostics) {
80
+ const refs = [...new Set(groups.flatMap((group) => group.commits))].filter(
81
+ (ref) => ref.length > 0
82
+ );
83
+ const resolved = await Promise.all(refs.map((ref) => resolveCommit(repoPath, ref, diagnostics)));
84
+ const commits = {};
85
+ for (const commit of resolved) {
86
+ if (commit) {
87
+ commits[commit.ref] = commit;
88
+ }
89
+ }
90
+ return commits;
91
+ }
92
+ async function resolveCommit(repoPath, ref, diagnostics) {
93
+ try {
94
+ const output = await runGit(repoPath, [
95
+ "show",
96
+ "-s",
97
+ `--format=${COMMIT_FORMAT}`,
98
+ `${ref}^{commit}`
99
+ ]);
100
+ const fields = output.replace(/\n$/u, "").split(COMMIT_FIELD_SEPARATOR);
101
+ const [sha = "", shortSha = "", authorName = "", authorEmail = "", authorDate = ""] = fields;
102
+ const subject = fields[5] ?? "";
103
+ const body = (fields[6] ?? "").trim();
104
+ return {
105
+ ref,
106
+ sha,
107
+ shortSha,
108
+ subject,
109
+ body,
110
+ authorName,
111
+ authorEmail,
112
+ ...authorDate.length === 0 ? {} : { authorDate }
113
+ };
114
+ } catch (error) {
115
+ diagnostics.push({
116
+ severity: "warning",
117
+ message: `Failed to resolve commit ${ref}: ${formatGitError(error)}`,
118
+ code: "commit_resolve_failed"
119
+ });
120
+ return void 0;
121
+ }
122
+ }
123
+ async function validateCommit(repoPath, commit, field, diagnostics) {
124
+ if (!commit) {
125
+ return;
126
+ }
127
+ try {
128
+ await runGit(repoPath, ["rev-parse", "--verify", `${commit}^{commit}`]);
129
+ } catch (error) {
130
+ diagnostics.push({
131
+ severity: "error",
132
+ message: `frontmatter.${field} does not resolve to a commit: ${formatGitError(error)}`,
133
+ code: "invalid_commit"
134
+ });
135
+ }
136
+ }
137
+ async function getNameStatus(repoPath, mergeBase, currentCommit, diagnostics) {
138
+ if (!mergeBase || !currentCommit) {
139
+ return [];
140
+ }
141
+ try {
142
+ const output = await runGit(repoPath, [
143
+ "diff",
144
+ "--name-status",
145
+ "-M",
146
+ mergeBase,
147
+ currentCommit
148
+ ]);
149
+ return parseNameStatus(output);
150
+ } catch (error) {
151
+ diagnostics.push({
152
+ severity: "error",
153
+ message: `Failed to read git name-status diff: ${formatGitError(error)}`,
154
+ code: "git_name_status_failed"
155
+ });
156
+ return [];
157
+ }
158
+ }
159
+ async function getNumstat(repoPath, mergeBase, currentCommit, diagnostics) {
160
+ const entries = /* @__PURE__ */ new Map();
161
+ if (!mergeBase || !currentCommit) {
162
+ return entries;
163
+ }
164
+ try {
165
+ const output = await runGit(repoPath, ["diff", "--numstat", "-M", mergeBase, currentCommit]);
166
+ for (const line of output.split("\n")) {
167
+ if (!line.trim()) {
168
+ continue;
169
+ }
170
+ const [additionsRaw, deletionsRaw, filePath] = line.split(" ");
171
+ if (!filePath) {
172
+ continue;
173
+ }
174
+ const binary = additionsRaw === "-" || deletionsRaw === "-";
175
+ const entry = {
176
+ additions: binary ? 0 : Number(additionsRaw),
177
+ deletions: binary ? 0 : Number(deletionsRaw),
178
+ binary
179
+ };
180
+ entries.set(normalizeDiffPath(filePath), entry);
181
+ const renameTarget = parseRenameNumstatPath(filePath);
182
+ if (renameTarget) {
183
+ entries.set(renameTarget, entry);
184
+ }
185
+ }
186
+ } catch (error) {
187
+ diagnostics.push({
188
+ severity: "error",
189
+ message: `Failed to read git numstat diff: ${formatGitError(error)}`,
190
+ code: "git_numstat_failed"
191
+ });
192
+ }
193
+ return entries;
194
+ }
195
+ async function resolveFile(input) {
196
+ const diagnostics = [];
197
+ const status = input.status?.status ?? "unknown";
198
+ const oldPath = input.status?.oldPath ?? (status === "added" ? void 0 : input.status?.path ?? input.reviewPath);
199
+ const newPath = input.status?.newPath ?? (status === "deleted" ? void 0 : input.status?.path ?? input.reviewPath);
200
+ const oldText = oldPath === void 0 ? "" : await readFileAtCommit(input.repoPath, input.mergeBase, oldPath, diagnostics);
201
+ const newText = newPath === void 0 ? "" : await readFileAtCommit(input.repoPath, input.currentCommit, newPath, diagnostics);
202
+ return {
203
+ path: input.reviewPath,
204
+ ...oldPath === void 0 ? {} : { oldPath },
205
+ ...newPath === void 0 ? {} : { newPath },
206
+ status,
207
+ oldText,
208
+ newText,
209
+ additions: input.numstat?.additions ?? 0,
210
+ deletions: input.numstat?.deletions ?? 0,
211
+ ...input.numstat?.binary === true ? { binary: true } : {},
212
+ diagnostics
213
+ };
214
+ }
215
+ async function readFileAtCommit(repoPath, commit, filePath, diagnostics) {
216
+ try {
217
+ return await runGit(repoPath, ["show", `${commit}:${filePath}`], 20 * 1024 * 1024);
218
+ } catch (error) {
219
+ diagnostics.push({
220
+ severity: "error",
221
+ message: `Failed to read ${filePath} at ${commit}: ${formatGitError(error)}`,
222
+ code: "git_show_failed"
223
+ });
224
+ return "";
225
+ }
226
+ }
227
+ function sparseOrBlank(text, range) {
228
+ if (range === void 0) {
229
+ const lineCount = countLines(text);
230
+ return lineCount === 0 ? "" : Array.from({ length: lineCount }, () => "").join("\n");
231
+ }
232
+ return createSparseTextForRanges(text, [range]);
233
+ }
234
+ function parseNameStatus(output) {
235
+ const entries = [];
236
+ for (const line of output.split("\n")) {
237
+ if (!line.trim()) {
238
+ continue;
239
+ }
240
+ const parts = line.split(" ");
241
+ const statusRaw = parts[0] ?? "";
242
+ const code = statusRaw[0];
243
+ if (code === "R") {
244
+ const oldPath = normalizeDiffPath(parts[1] ?? "");
245
+ const newPath = normalizeDiffPath(parts[2] ?? "");
246
+ entries.push({ status: "renamed", path: newPath, oldPath, newPath });
247
+ continue;
248
+ }
249
+ const filePath = normalizeDiffPath(parts[1] ?? "");
250
+ if (!filePath) {
251
+ continue;
252
+ }
253
+ if (code === "A") {
254
+ entries.push({ status: "added", path: filePath, newPath: filePath });
255
+ } else if (code === "D") {
256
+ entries.push({ status: "deleted", path: filePath, oldPath: filePath });
257
+ } else if (code === "M") {
258
+ entries.push({ status: "modified", path: filePath, oldPath: filePath, newPath: filePath });
259
+ } else {
260
+ entries.push({ status: "unknown", path: filePath, oldPath: filePath, newPath: filePath });
261
+ }
262
+ }
263
+ return entries;
264
+ }
265
+ function buildStatusMap(entries) {
266
+ const statusByPath = /* @__PURE__ */ new Map();
267
+ for (const entry of entries) {
268
+ statusByPath.set(entry.path, entry);
269
+ if (entry.oldPath) {
270
+ statusByPath.set(entry.oldPath, entry);
271
+ }
272
+ if (entry.newPath) {
273
+ statusByPath.set(entry.newPath, entry);
274
+ }
275
+ }
276
+ return statusByPath;
277
+ }
278
+ function normalizeDiffPath(filePath) {
279
+ return filePath.replace(/\\/gu, "/");
280
+ }
281
+ function parseRenameNumstatPath(filePath) {
282
+ const brace = /^(.*)\{(.+?) => (.+?)\}(.*)$/u.exec(filePath);
283
+ if (brace) {
284
+ return normalizeDiffPath(`${brace[1] ?? ""}${brace[3] ?? ""}${brace[4] ?? ""}`);
285
+ }
286
+ const arrow = /^(.+?) => (.+)$/u.exec(filePath);
287
+ if (arrow) {
288
+ return normalizeDiffPath(arrow[2] ?? "");
289
+ }
290
+ return void 0;
291
+ }
292
+ async function runGit(repoPath, args, maxBuffer = 8 * 1024 * 1024) {
293
+ const result = await execFileAsync("git", args, {
294
+ cwd: repoPath,
295
+ encoding: "utf8",
296
+ maxBuffer
297
+ });
298
+ return result.stdout;
299
+ }
300
+ function formatGitError(error) {
301
+ if (error instanceof Error) {
302
+ return error.message;
303
+ }
304
+ return String(error);
305
+ }
306
+ export {
307
+ resolveReviewBundle
308
+ };
@@ -0,0 +1,33 @@
1
+ import { c as collectBundleDiagnostics, h as hasErrorDiagnostics } from "./sparse-text-D7zcV2O5.js";
2
+ import { a, b, p, d, e, v, f } from "./sparse-text-D7zcV2O5.js";
3
+ const REVIEW_BUNDLE_SNAPSHOT_VERSION = 1;
4
+ function createReviewBundleSnapshot(bundle, renderedAt = /* @__PURE__ */ new Date()) {
5
+ return {
6
+ version: REVIEW_BUNDLE_SNAPSHOT_VERSION,
7
+ renderedAt: renderedAt.toISOString(),
8
+ source: {
9
+ reviewFilePath: bundle.reviewFilePath ?? "",
10
+ repoPath: bundle.repoPath ?? "",
11
+ mergeBase: bundle.document.frontmatter.mergeBase,
12
+ currentCommit: bundle.document.frontmatter.currentCommit
13
+ },
14
+ bundle
15
+ };
16
+ }
17
+ function isReviewBundleSnapshot(value) {
18
+ return typeof value === "object" && value !== null && "version" in value && value.version === REVIEW_BUNDLE_SNAPSHOT_VERSION && "bundle" in value && typeof value.bundle === "object" && value.bundle !== null;
19
+ }
20
+ export {
21
+ REVIEW_BUNDLE_SNAPSHOT_VERSION,
22
+ collectBundleDiagnostics,
23
+ a as countLines,
24
+ createReviewBundleSnapshot,
25
+ b as createSparseTextForRanges,
26
+ hasErrorDiagnostics,
27
+ isReviewBundleSnapshot,
28
+ p as parseLineRange,
29
+ d as parseReviewMarkdown,
30
+ e as parseReviewRef,
31
+ v as validateParsedReviewDocument,
32
+ f as validateResolvedBlock
33
+ };
@@ -0,0 +1,17 @@
1
+ const REVIEW_GLOBAL_NAME = "__LODY_REVIEW__";
2
+ const PLACEHOLDER = "<!--LODY_REVIEW_DATA-->";
3
+ function injectReviewSnapshot(template, data) {
4
+ const json = JSON.stringify(data).replace(/</g, "\\u003c");
5
+ const script = `<script>window.${REVIEW_GLOBAL_NAME}=${json}<\/script>`;
6
+ if (template.includes(PLACEHOLDER)) {
7
+ return template.replace(PLACEHOLDER, script);
8
+ }
9
+ if (template.includes("</head>")) {
10
+ return template.replace("</head>", `${script}</head>`);
11
+ }
12
+ return `${script}${template}`;
13
+ }
14
+ export {
15
+ REVIEW_GLOBAL_NAME,
16
+ injectReviewSnapshot
17
+ };