comprehende 0.1.0 → 0.3.0

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.
Files changed (73) hide show
  1. package/README.md +20 -49
  2. package/dist/api/error.js +8 -0
  3. package/dist/api/live.js +249 -0
  4. package/dist/api/paths.js +96 -0
  5. package/dist/api/snapshot.js +45 -0
  6. package/dist/api/types.js +1 -0
  7. package/dist/cli/args.js +7 -1
  8. package/dist/cli/commands.js +6 -0
  9. package/dist/cli/main.js +22 -10
  10. package/dist/git/blob.js +21 -0
  11. package/dist/git/diff.js +44 -2
  12. package/dist/git/exec.js +29 -3
  13. package/dist/git/lfs.js +39 -0
  14. package/dist/schema/cli-pin.js +18 -0
  15. package/dist/schema/image-diff.js +51 -0
  16. package/dist/schema/image.js +35 -0
  17. package/dist/schema/parse.js +5 -1
  18. package/dist/schema/skill-sync.js +90 -0
  19. package/dist/server/http.js +83 -197
  20. package/dist/ui/assets/index-BxIxxAYx.css +1 -0
  21. package/dist/ui/assets/index-D4bY-beP.js +1598 -0
  22. package/dist/ui/index.html +16 -2
  23. package/package.json +15 -11
  24. package/skills/comprehende/SKILL.md +44 -29
  25. package/skills/comprehende/references/example.md +21 -0
  26. package/skills/comprehende/references/review.schema.json +6 -1
  27. package/dist/cli/args.d.ts +0 -20
  28. package/dist/cli/args.js.map +0 -1
  29. package/dist/cli/commands.d.ts +0 -12
  30. package/dist/cli/commands.js.map +0 -1
  31. package/dist/cli/main.d.ts +0 -2
  32. package/dist/cli/main.js.map +0 -1
  33. package/dist/git/blame.d.ts +0 -8
  34. package/dist/git/blame.js.map +0 -1
  35. package/dist/git/diff.d.ts +0 -13
  36. package/dist/git/diff.js.map +0 -1
  37. package/dist/git/exec.d.ts +0 -10
  38. package/dist/git/exec.js.map +0 -1
  39. package/dist/git/log.d.ts +0 -8
  40. package/dist/git/log.js.map +0 -1
  41. package/dist/git/repo.d.ts +0 -7
  42. package/dist/git/repo.js.map +0 -1
  43. package/dist/git/show.d.ts +0 -2
  44. package/dist/git/show.js.map +0 -1
  45. package/dist/package-root.d.ts +0 -2
  46. package/dist/package-root.js.map +0 -1
  47. package/dist/review/coverage.d.ts +0 -20
  48. package/dist/review/coverage.js.map +0 -1
  49. package/dist/review/effort.d.ts +0 -1
  50. package/dist/review/effort.js +0 -17
  51. package/dist/review/effort.js.map +0 -1
  52. package/dist/review/generate.d.ts +0 -20
  53. package/dist/review/generate.js +0 -290
  54. package/dist/review/generate.js.map +0 -1
  55. package/dist/schema/blame-runs.d.ts +0 -14
  56. package/dist/schema/blame-runs.js.map +0 -1
  57. package/dist/schema/hunk-meta.d.ts +0 -26
  58. package/dist/schema/hunk-meta.js.map +0 -1
  59. package/dist/schema/hunk-patch.d.ts +0 -2
  60. package/dist/schema/hunk-patch.js +0 -49
  61. package/dist/schema/hunk-patch.js.map +0 -1
  62. package/dist/schema/identity.d.ts +0 -3
  63. package/dist/schema/identity.js.map +0 -1
  64. package/dist/schema/parse.d.ts +0 -13
  65. package/dist/schema/parse.js.map +0 -1
  66. package/dist/schema/skill-paths.d.ts +0 -7
  67. package/dist/schema/skill-paths.js.map +0 -1
  68. package/dist/schema/types.d.ts +0 -73
  69. package/dist/schema/types.js.map +0 -1
  70. package/dist/server/http.d.ts +0 -12
  71. package/dist/server/http.js.map +0 -1
  72. package/dist/ui/assets/index-BCZwzr3E.js +0 -1585
  73. package/dist/ui/assets/index-DsGDgUsX.css +0 -1
package/README.md CHANGED
@@ -1,62 +1,19 @@
1
1
  # Comprehende
2
2
 
3
- Local review assistant for git diffs. Groups changes by review concern and shows them in a fixed UI. Diffs always come from git in the repo you run the CLI in — never from the model, never stored in the review document. The review UI paints `git diff` output with [`@pierre/diffs`](https://diffs.com), the same renderer [T3 Code](https://github.com/pingdotgg/t3code) uses. Full file and blame views use Pierre’s file renderer from `git show` / `git blame`.
3
+ Local review assistant for git diffs. Groups changes by review concern and shows them in a fixed UI. Diffs always come from git in the repo you run the CLI in — never from the model, never stored in the review document.
4
4
 
5
- Product intent: [AGENTS.md](./AGENTS.md). Implementation plan: [PLAN.md](./PLAN.md).
6
-
7
- ## Install
8
-
9
- Node.js 24+. Run the CLI **inside** the repository you want to review. There is no `--repo` flag.
10
-
11
- ```sh
12
- npx comprehende@0.1.0 index --base origin/main
13
- npx comprehende@0.1.0 validate --data review.json
14
- npx comprehende@0.1.0 serve --data review.json --open
15
- ```
16
-
17
- Pin the version. After a release, that version is on npm.
18
-
19
- ## Review a clone
5
+ ## Install the skill
20
6
 
21
7
  ```sh
22
- git clone <url>
23
- cd <repo>
24
-
25
- npx comprehende@0.1.0 index --base origin/main
26
- # write review.json from those hunk refs (the skill does this)
27
-
28
- npx comprehende@0.1.0 validate --data review.json
29
- npx comprehende@0.1.0 serve --data review.json --open
8
+ npx skills add matemolnar8/comprehende
30
9
  ```
31
10
 
32
- Three-dot range (`base...head`) is the merge-request / branch diff. `--base` defaults to `origin/HEAD` (or `main`/`master`). `--head` defaults to `HEAD`. `serve` binds `127.0.0.1` only and re-reads git on every request.
33
-
34
- ## Install the skill
35
-
36
11
  From this checkout:
37
12
 
38
13
  ```sh
39
14
  npx skills add ./ --skill comprehende
40
15
  ```
41
16
 
42
- Once the package is on npm:
43
-
44
- ```sh
45
- npx skills add matemolnar8/comprehende
46
- ```
47
-
48
- The skill is the agent workflow: `index` → read the git diffs → write `review.json` (refs + summaries only) → `validate` → `serve`. Edit `src/schema/review.schema.json` and run `pnpm sync:skill` so the copy in the skill folder stays identical.
49
-
50
- ## Commands
51
-
52
- ```
53
- comprehende index [--base <ref>] [--head <ref>]
54
- comprehende validate --data <review.json>
55
- comprehende serve --data <review.json> [--port] [--open]
56
- ```
57
-
58
- Defaults: `--head HEAD`, `--base` is `origin/HEAD` (or `main`/`master`).
59
-
60
17
  ## Develop
61
18
 
62
19
  [pnpm](https://pnpm.io/).
@@ -73,11 +30,13 @@ pnpm dev -- --help
73
30
 
74
31
  `pnpm build` emits `dist/cli` and `dist/ui`. `prepack` runs that build, so `pnpm pack` / `pnpm publish` always ship the UI.
75
32
 
76
- `pnpm dev` and `pnpm exec` run with this package as cwd, so they only make sense when *this* repo is the one under review. To review a different project from a checkout, `cd` into it and run `npx comprehende@0.1.0` (or `node /path/to/comprehende/dist/cli/main.js` after `pnpm build`).
33
+ `comprehende serve` and `comprehende export` share one UI and one git payload layer. Serve computes those payloads on each request. Export writes the same JSON (and image bytes) next to the UI so any static file server can host the review.
34
+
35
+ `pnpm dev` and `pnpm exec` run with this package as cwd, so they only make sense when _this_ repo is the one under review. To review a different project from a checkout, `cd` into it and run `npx comprehende@0.3.0` (or `node /path/to/comprehende/dist/cli/main.js` after `pnpm build`).
77
36
 
78
37
  ## Release
79
38
 
80
- Bump `version` in `package.json` when the CLI or UI changes. Do not bump it for skill-only edits.
39
+ Bump `version` in `package.json` when the CLI or UI changes, then run `pnpm sync:skill` so the skill pin matches. Pre-commit and `pnpm test` fail if the staged package version and skill pin differ. Do not bump for skill-only edits.
81
40
 
82
41
  Push to `main`. CI packs and tests the tarball on every change. If the version is not on npm yet, CI publishes it. Skill-only commits keep the same version, so they do not publish.
83
42
 
@@ -91,4 +50,16 @@ cd fixtures/repo
91
50
  node ../../dist/cli/main.js serve --data ../example/review.json
92
51
  ```
93
52
 
94
- `pnpm fixture` writes a tiny git repo to `fixtures/repo` (gitignored) and a refs-only `fixtures/example/review.json`. Serve with cwd set to `fixtures/repo`.
53
+ Export a static copy (cwd still `fixtures/repo`):
54
+
55
+ ```sh
56
+ node ../../dist/cli/main.js export --data ../example/review.json --out ../../fixtures/site
57
+ ```
58
+
59
+ The folder has the UI plus frozen `api/*.json` payloads and image bytes. There is no git in that folder. Host it with any static file server:
60
+
61
+ ```sh
62
+ python3 -m http.server --directory ../../fixtures/site 8080
63
+ ```
64
+
65
+ `pnpm fixture` writes a tiny git repo to `fixtures/repo` (gitignored) and a refs-only `fixtures/example/review.json`. Serve or export with cwd set to `fixtures/repo`.
@@ -0,0 +1,8 @@
1
+ export class ApiError extends Error {
2
+ status;
3
+ constructor(status, message) {
4
+ super(message);
5
+ this.name = "ApiError";
6
+ this.status = status;
7
+ }
8
+ }
@@ -0,0 +1,249 @@
1
+ import { loadDocument } from "../cli/commands.js";
2
+ import { blameFile } from "../git/blame.js";
3
+ import { readImageBlob } from "../git/blob.js";
4
+ import { fileLanguage, filePatchFromGit, resolveSource, toHunkRef } from "../git/diff.js";
5
+ import { GitError } from "../git/exec.js";
6
+ import { listCommits } from "../git/log.js";
7
+ import { mergeBase } from "../git/repo.js";
8
+ import { showFile } from "../git/show.js";
9
+ import { coverReview } from "../review/coverage.js";
10
+ import { ApiError } from "./error.js";
11
+ export async function openReview(cwd, dataPath) {
12
+ const document = await loadDocument(dataPath);
13
+ const resolvedRefs = await resolveSource(cwd, document.source.baseRef, document.source.headRef);
14
+ const { files, coverage } = await coverReview(cwd, document);
15
+ const [mergeBaseSha, commits] = await Promise.all([
16
+ mergeBase(cwd, document.source.baseRef, document.source.headRef),
17
+ listCommits(cwd, document.source.baseRef, document.source.headRef),
18
+ ]);
19
+ return {
20
+ cwd,
21
+ document,
22
+ resolved: {
23
+ baseRef: document.source.baseRef,
24
+ headRef: document.source.headRef,
25
+ range: document.source.range ?? `${document.source.baseRef}...${document.source.headRef}`,
26
+ baseSha: resolvedRefs.baseSha,
27
+ headSha: resolvedRefs.headSha,
28
+ },
29
+ files,
30
+ coverage,
31
+ mergeBaseSha,
32
+ commits,
33
+ };
34
+ }
35
+ export function reviewPayload(ctx) {
36
+ const { document, resolved, files, coverage, commits } = ctx;
37
+ return {
38
+ document,
39
+ resolved,
40
+ coverage: {
41
+ totalHunks: coverage.totalHunks,
42
+ assignedHunks: coverage.assignedHunks,
43
+ unassignedCount: coverage.unassigned.length,
44
+ staleCount: coverage.stale.length,
45
+ },
46
+ groups: coverage.groups
47
+ .slice()
48
+ .sort((a, b) => a.group.suggestedOrder - b.group.suggestedOrder || a.group.id.localeCompare(b.group.id))
49
+ .map((group) => ({
50
+ id: group.group.id,
51
+ title: group.group.title,
52
+ summary: group.group.summary,
53
+ lookFor: group.group.lookFor ?? [],
54
+ dependsOn: group.group.dependsOn ?? [],
55
+ part: group.group.part,
56
+ suggestedOrder: group.group.suggestedOrder,
57
+ hunkCount: group.hunks.length,
58
+ staleCount: group.stale.length,
59
+ files: uniquePaths(group.hunks),
60
+ })),
61
+ unassigned: {
62
+ hunkCount: coverage.unassigned.length,
63
+ files: uniquePaths(coverage.unassigned),
64
+ },
65
+ stale: coverage.stale,
66
+ files: files.map((file) => {
67
+ const entry = {
68
+ path: file.path,
69
+ status: file.status,
70
+ binary: file.binary,
71
+ image: file.image,
72
+ hunkCount: file.hunks.length,
73
+ };
74
+ if (file.oldPath !== undefined) {
75
+ entry.oldPath = file.oldPath;
76
+ }
77
+ return entry;
78
+ }),
79
+ skipped: files.filter((file) => file.binary && !file.image).map((file) => ({ path: file.path, reason: "binary" })),
80
+ commits,
81
+ };
82
+ }
83
+ export function hunksPayload(ctx, groupId) {
84
+ if (groupId === "") {
85
+ throw new ApiError(400, "missing group");
86
+ }
87
+ if (groupId === "unassigned") {
88
+ return serializeLayer(ctx.files, ctx.coverage.unassigned);
89
+ }
90
+ const group = ctx.coverage.groups.find((item) => item.group.id === groupId);
91
+ if (group === undefined) {
92
+ throw new ApiError(404, `unknown group "${groupId}"`);
93
+ }
94
+ return serializeLayer(ctx.files, group.hunks);
95
+ }
96
+ export async function filePayload(ctx, path, side) {
97
+ const file = findFile(ctx.files, path);
98
+ const lookup = side === "old" ? (file.oldPath ?? file.path) : file.path;
99
+ assertSideExists(file, side);
100
+ const ref = side === "old" ? ctx.mergeBaseSha : ctx.document.source.headRef;
101
+ try {
102
+ const content = await showFile(ctx.cwd, ref, lookup);
103
+ return { path: lookup, ref, side, content, language: fileLanguage(lookup) };
104
+ }
105
+ catch (error) {
106
+ throw new ApiError(404, error instanceof Error ? error.message : "file not found");
107
+ }
108
+ }
109
+ export async function blamePayload(ctx, path, side) {
110
+ const file = findFile(ctx.files, path);
111
+ const lookup = side === "old" ? (file.oldPath ?? file.path) : file.path;
112
+ assertSideExists(file, side);
113
+ const ref = side === "old" ? ctx.mergeBaseSha : ctx.document.source.headRef;
114
+ try {
115
+ const lines = await blameFile(ctx.cwd, ref, lookup);
116
+ return { path: lookup, ref, side, lines };
117
+ }
118
+ catch (error) {
119
+ throw new ApiError(404, error instanceof Error ? error.message : "blame not available");
120
+ }
121
+ }
122
+ export async function imagePayload(ctx, path, side) {
123
+ const file = findFile(ctx.files, path);
124
+ if (!file.image) {
125
+ throw new ApiError(404, "path is not an image in the live diff");
126
+ }
127
+ const lookup = side === "old" ? (file.oldPath ?? file.path) : file.path;
128
+ assertSideExists(file, side);
129
+ const ref = side === "old" ? ctx.mergeBaseSha : ctx.document.source.headRef;
130
+ try {
131
+ const blob = await readImageBlob(ctx.cwd, ref, lookup);
132
+ if (!blob.ok) {
133
+ throw new ApiError(404, `Git LFS object sha256:${blob.oid} is not in this clone`);
134
+ }
135
+ return { encoding: "bytes", mediaType: blob.mediaType, body: blob.bytes };
136
+ }
137
+ catch (error) {
138
+ if (error instanceof ApiError) {
139
+ throw error;
140
+ }
141
+ throw new ApiError(404, error instanceof Error ? error.message : "image not found");
142
+ }
143
+ }
144
+ export async function renderResource(ctx, resource) {
145
+ switch (resource.kind) {
146
+ case "review":
147
+ return { encoding: "json", body: reviewPayload(ctx) };
148
+ case "hunks":
149
+ return { encoding: "json", body: hunksPayload(ctx, resource.group) };
150
+ case "file":
151
+ return { encoding: "json", body: await filePayload(ctx, resource.path, resource.side) };
152
+ case "blame":
153
+ return { encoding: "json", body: await blamePayload(ctx, resource.path, resource.side) };
154
+ case "image":
155
+ return imagePayload(ctx, resource.path, resource.side);
156
+ }
157
+ }
158
+ export function listResources(ctx) {
159
+ const resources = [{ kind: "review" }, { kind: "hunks", group: "unassigned" }];
160
+ for (const group of ctx.document.groups) {
161
+ resources.push({ kind: "hunks", group: group.id });
162
+ }
163
+ for (const file of ctx.files) {
164
+ if (file.image) {
165
+ for (const side of sidesFor(file)) {
166
+ resources.push({ kind: "image", path: file.path, side });
167
+ }
168
+ continue;
169
+ }
170
+ for (const side of sidesFor(file)) {
171
+ resources.push({ kind: "file", path: file.path, side });
172
+ resources.push({ kind: "blame", path: file.path, side });
173
+ }
174
+ }
175
+ return resources;
176
+ }
177
+ function sidesFor(file) {
178
+ if (file.status === "added") {
179
+ return ["new"];
180
+ }
181
+ if (file.status === "deleted") {
182
+ return ["old"];
183
+ }
184
+ return ["old", "new"];
185
+ }
186
+ function serializeLayer(files, hunks) {
187
+ const groups = [];
188
+ for (const hunk of hunks) {
189
+ const existing = groups.find((group) => group.file.path === hunk.path);
190
+ if (existing !== undefined) {
191
+ existing.hunks.push(hunk);
192
+ continue;
193
+ }
194
+ const file = files.find((item) => item.path === hunk.path);
195
+ if (file === undefined) {
196
+ continue;
197
+ }
198
+ groups.push({ file, hunks: [hunk] });
199
+ }
200
+ const serializedFiles = groups.map(({ file, hunks: fileHunks }) => {
201
+ const next = {
202
+ path: file.path,
203
+ kind: file.image ? "image" : "text",
204
+ status: file.status,
205
+ patch: file.image ? file.headerPatch : filePatchFromGit(file, fileHunks),
206
+ hunks: fileHunks.map(serializeHunk),
207
+ };
208
+ if (file.oldPath !== undefined) {
209
+ next.oldPath = file.oldPath;
210
+ }
211
+ return next;
212
+ });
213
+ return { hunks: hunks.map(serializeHunk), files: serializedFiles };
214
+ }
215
+ function serializeHunk(hunk) {
216
+ return {
217
+ ...toHunkRef(hunk),
218
+ header: hunk.header,
219
+ language: fileLanguage(hunk.path),
220
+ lines: hunk.lines,
221
+ };
222
+ }
223
+ function findFile(files, path) {
224
+ const file = files.find((item) => item.path === path || item.oldPath === path);
225
+ if (file === undefined) {
226
+ throw new ApiError(404, `path is not in the live diff: ${path}`);
227
+ }
228
+ return file;
229
+ }
230
+ function assertSideExists(file, side) {
231
+ if (side === "old" && file.status === "added") {
232
+ throw new ApiError(404, "file did not exist on the base side");
233
+ }
234
+ if (side === "new" && file.status === "deleted") {
235
+ throw new ApiError(404, "file does not exist on the head side");
236
+ }
237
+ }
238
+ function uniquePaths(hunks) {
239
+ return [...new Set(hunks.map((hunk) => hunk.path))];
240
+ }
241
+ export function snapshotJson(body) {
242
+ return `${JSON.stringify(body)}\n`;
243
+ }
244
+ export function isUnavailableSnapshot(error) {
245
+ if (error instanceof ApiError) {
246
+ return error.status === 404 || error.status === 400;
247
+ }
248
+ return error instanceof GitError;
249
+ }
@@ -0,0 +1,96 @@
1
+ export function apiHref(resource) {
2
+ switch (resource.kind) {
3
+ case "review":
4
+ return "api/review.json";
5
+ case "hunks":
6
+ return `api/hunks/${encodeURIComponent(resource.group)}.json`;
7
+ case "file":
8
+ return `api/files/${resource.side}/${encodeFilePath(resource.path)}.json`;
9
+ case "blame":
10
+ return `api/blame/${resource.side}/${encodeFilePath(resource.path)}.json`;
11
+ case "image":
12
+ return `api/images/${resource.side}/${encodeFilePath(resource.path)}`;
13
+ }
14
+ }
15
+ /** Decoded relative path under the site root. Static hosts map encoded request URLs onto this. */
16
+ export function apiFsRel(resource) {
17
+ switch (resource.kind) {
18
+ case "review":
19
+ return "api/review.json";
20
+ case "hunks":
21
+ return `api/hunks/${encodeURIComponent(resource.group)}.json`;
22
+ case "file":
23
+ return `api/files/${resource.side}/${resource.path}.json`;
24
+ case "blame":
25
+ return `api/blame/${resource.side}/${resource.path}.json`;
26
+ case "image":
27
+ return `api/images/${resource.side}/${resource.path}`;
28
+ }
29
+ }
30
+ export function parseApiPath(pathname) {
31
+ const parts = pathname.split("/").filter((part) => part !== "").map(decodeSegment);
32
+ if (parts[0] !== "api" || parts[1] === undefined) {
33
+ return undefined;
34
+ }
35
+ if (parts[1] === "review.json" && parts.length === 2) {
36
+ return { kind: "review" };
37
+ }
38
+ if (parts[1] === "hunks" && parts.length === 3 && parts[2] !== undefined) {
39
+ const group = jsonStem(parts[2]);
40
+ if (group === undefined) {
41
+ return undefined;
42
+ }
43
+ return { kind: "hunks", group };
44
+ }
45
+ if (parts[1] === "images" && parts.length >= 4 && parts[2] !== undefined) {
46
+ const side = parts[2];
47
+ if (side !== "old" && side !== "new") {
48
+ return undefined;
49
+ }
50
+ const path = parts.slice(3).join("/");
51
+ if (!isRepoPath(path)) {
52
+ return undefined;
53
+ }
54
+ return { kind: "image", path, side };
55
+ }
56
+ if ((parts[1] === "files" || parts[1] === "blame") && parts.length >= 4 && parts[2] !== undefined) {
57
+ const side = parts[2];
58
+ if (side !== "old" && side !== "new") {
59
+ return undefined;
60
+ }
61
+ const rest = parts.slice(3);
62
+ const last = rest.at(-1);
63
+ const stem = last === undefined ? undefined : jsonStem(last);
64
+ if (stem === undefined) {
65
+ return undefined;
66
+ }
67
+ rest[rest.length - 1] = stem;
68
+ const path = rest.join("/");
69
+ if (!isRepoPath(path)) {
70
+ return undefined;
71
+ }
72
+ return { kind: parts[1] === "files" ? "file" : "blame", path, side };
73
+ }
74
+ return undefined;
75
+ }
76
+ function encodeFilePath(path) {
77
+ return path.split("/").map(encodeURIComponent).join("/");
78
+ }
79
+ function decodeSegment(segment) {
80
+ try {
81
+ return decodeURIComponent(segment);
82
+ }
83
+ catch {
84
+ return segment;
85
+ }
86
+ }
87
+ function jsonStem(file) {
88
+ if (!file.endsWith(".json")) {
89
+ return undefined;
90
+ }
91
+ const stem = file.slice(0, -".json".length);
92
+ return stem === "" ? undefined : stem;
93
+ }
94
+ function isRepoPath(path) {
95
+ return path !== "" && !path.startsWith("/") && !path.includes("\0") && !path.split("/").includes("..");
96
+ }
@@ -0,0 +1,45 @@
1
+ import { cp, mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { findPackageRoot } from "../package-root.js";
5
+ import { isUnavailableSnapshot, listResources, openReview, renderResource, snapshotJson, } from "./live.js";
6
+ import { apiFsRel } from "./paths.js";
7
+ export async function exportStaticSite(opts) {
8
+ const outDir = resolve(opts.outDir);
9
+ const uiRoot = opts.uiRoot ?? join(findPackageRoot(), "dist/ui");
10
+ if (!existsSync(uiRoot)) {
11
+ throw new Error("UI is missing from this install. Reinstall comprehende from npm, or from a git checkout run `pnpm build`.");
12
+ }
13
+ if (existsSync(join(outDir, ".git"))) {
14
+ throw new Error(`refusing to write export into a git repository: ${outDir}`);
15
+ }
16
+ const ctx = opts.ctx ?? (await openReview(opts.cwd, opts.dataPath));
17
+ await mkdir(outDir, { recursive: true });
18
+ await rm(join(outDir, "assets"), { recursive: true, force: true });
19
+ await rm(join(outDir, "api"), { recursive: true, force: true });
20
+ await cp(uiRoot, outDir, { recursive: true });
21
+ const apiFiles = [];
22
+ for (const resource of listResources(ctx)) {
23
+ let body;
24
+ try {
25
+ body = await renderResource(ctx, resource);
26
+ }
27
+ catch (error) {
28
+ if (isUnavailableSnapshot(error)) {
29
+ continue;
30
+ }
31
+ throw error;
32
+ }
33
+ const rel = apiFsRel(resource);
34
+ const abs = join(outDir, ...rel.split("/"));
35
+ await mkdir(dirname(abs), { recursive: true });
36
+ if (body.encoding === "json") {
37
+ await writeFile(abs, snapshotJson(body.body));
38
+ }
39
+ else {
40
+ await writeFile(abs, Buffer.from(body.body));
41
+ }
42
+ apiFiles.push(rel);
43
+ }
44
+ return { outDir, apiFiles };
45
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli/args.js CHANGED
@@ -1,4 +1,4 @@
1
- const COMMANDS = new Set(["index", "validate", "serve"]);
1
+ const COMMANDS = new Set(["index", "validate", "serve", "export"]);
2
2
  export const USAGE = `Usage: comprehende <command> [options]
3
3
 
4
4
  Run inside the git repository under review. Cwd is the repo.
@@ -13,16 +13,21 @@ Commands:
13
13
  serve --data <review.json> [--port <n>] [--open]
14
14
  Serve the local UI on 127.0.0.1 (re-reads git on each request)
15
15
 
16
+ export --data <review.json> --out <dir>
17
+ Write a static site (same UI + frozen git payloads). No server after that.
18
+
16
19
  Options:
17
20
  --base <ref> Base ref (default: origin/HEAD or main/master)
18
21
  --head <ref> Head ref (default: HEAD)
19
22
  --data <path> Review document path
23
+ --out <dir> Output directory for export
20
24
  --port <n> Listen port (default: 4567, 0 for ephemeral)
21
25
  --open Open the UI in a browser
22
26
  -h, --help Show this help
23
27
  -v, --version Show version
24
28
 
25
29
  Diffs always come from git in cwd. The review document is interpretation only.
30
+ Export is a point-in-time copy. Rebase or new commits need a new export.
26
31
  `;
27
32
  export function parseArgv(argv, cwd = process.cwd()) {
28
33
  const args = argv[0] === "--" ? argv.slice(1) : [...argv];
@@ -52,6 +57,7 @@ export function parseArgv(argv, cwd = process.cwd()) {
52
57
  base: flag(rest, "--base"),
53
58
  head: flag(rest, "--head"),
54
59
  data: flag(rest, "--data"),
60
+ out: flag(rest, "--out"),
55
61
  port,
56
62
  open: rest.includes("--open"),
57
63
  };
@@ -28,6 +28,12 @@ export function resolveDataPath(data, cwd) {
28
28
  }
29
29
  return isAbsolute(data) ? data : resolve(cwd, data);
30
30
  }
31
+ export function resolveOutPath(out, cwd) {
32
+ if (out === undefined) {
33
+ throw new Error("missing --out <dir>");
34
+ }
35
+ return isAbsolute(out) ? out : resolve(cwd, out);
36
+ }
31
37
  export async function cmdValidate(cwd, dataPath) {
32
38
  const document = await loadDocument(dataPath);
33
39
  await resolveSource(cwd, document.source.baseRef, document.source.headRef);
package/dist/cli/main.js CHANGED
@@ -4,9 +4,10 @@ import { realpathSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { parseArgv, USAGE } from "./args.js";
7
- import { cmdIndex, cmdValidate, loadDocument, resolveDataPath } from "./commands.js";
8
- import { resolveSource } from "../git/diff.js";
9
- import { coverReview, coverageErrors } from "../review/coverage.js";
7
+ import { cmdIndex, cmdValidate, resolveDataPath, resolveOutPath } from "./commands.js";
8
+ import { exportStaticSite } from "../api/snapshot.js";
9
+ import { openReview } from "../api/live.js";
10
+ import { coverageErrors } from "../review/coverage.js";
10
11
  import { assertWorkTree } from "../git/repo.js";
11
12
  import { readPackageVersion } from "../package-root.js";
12
13
  import { startServer } from "../server/http.js";
@@ -41,13 +42,8 @@ export async function run(argv) {
41
42
  }
42
43
  case "serve": {
43
44
  const dataPath = resolveDataPath(request.data, request.cwd);
44
- const document = await loadDocument(dataPath);
45
- await resolveSource(request.cwd, document.source.baseRef, document.source.headRef);
46
- const { coverage } = await coverReview(request.cwd, document);
47
- const problems = coverageErrors(coverage);
48
- if (problems.length > 0) {
49
- console.error(`coverage issues (serve continues; git wins, unassigned/stale are visible):\n${problems.join("\n\n")}`);
50
- }
45
+ const ctx = await openReview(request.cwd, dataPath);
46
+ warnCoverage(ctx.coverage, "serve continues; git wins, unassigned/stale are visible");
51
47
  const running = await startServer({ cwd: request.cwd, dataPath, port: request.port });
52
48
  console.log(running.url);
53
49
  console.error(`serving ${dataPath} cwd=${request.cwd} localhost only`);
@@ -57,6 +53,16 @@ export async function run(argv) {
57
53
  await waitForClose(running.server);
58
54
  return 0;
59
55
  }
56
+ case "export": {
57
+ const dataPath = resolveDataPath(request.data, request.cwd);
58
+ const outDir = resolveOutPath(request.out, request.cwd);
59
+ const ctx = await openReview(request.cwd, dataPath);
60
+ warnCoverage(ctx.coverage, "export continues; git wins, unassigned/stale are visible");
61
+ const result = await exportStaticSite({ cwd: request.cwd, dataPath, outDir, ctx });
62
+ console.log(result.outDir);
63
+ console.error(`exported ${dataPath} ${result.apiFiles.length} api files no git in the folder`);
64
+ return 0;
65
+ }
60
66
  }
61
67
  }
62
68
  catch (error) {
@@ -64,6 +70,12 @@ export async function run(argv) {
64
70
  return 1;
65
71
  }
66
72
  }
73
+ function warnCoverage(coverage, note) {
74
+ const problems = coverageErrors(coverage);
75
+ if (problems.length > 0) {
76
+ console.error(`coverage issues (${note}):\n${problems.join("\n\n")}`);
77
+ }
78
+ }
67
79
  function waitForClose(server) {
68
80
  return new Promise((resolve) => {
69
81
  const shutdown = () => {
@@ -0,0 +1,21 @@
1
+ import { imageMediaType } from "../schema/image.js";
2
+ import { gitBuffer } from "./exec.js";
3
+ import { parseLfsPointer, readLfsObject } from "./lfs.js";
4
+ import { assertSafePath, assertSafeRef } from "./repo.js";
5
+ export async function readBlob(cwd, ref, path) {
6
+ assertSafeRef(ref);
7
+ assertSafePath(path);
8
+ return gitBuffer(cwd, ["cat-file", "blob", `${ref}:${path}`]);
9
+ }
10
+ export async function readImageBlob(cwd, ref, path) {
11
+ const blob = await readBlob(cwd, ref, path);
12
+ const pointer = parseLfsPointer(blob);
13
+ if (pointer === undefined) {
14
+ return { ok: true, bytes: blob, mediaType: imageMediaType(path), lfs: false };
15
+ }
16
+ const object = await readLfsObject(cwd, pointer);
17
+ if (object === undefined) {
18
+ return { ok: false, oid: pointer.oid };
19
+ }
20
+ return { ok: true, bytes: object, mediaType: imageMediaType(path), lfs: true };
21
+ }