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.
- package/README.md +20 -49
- package/dist/api/error.js +8 -0
- package/dist/api/live.js +249 -0
- package/dist/api/paths.js +96 -0
- package/dist/api/snapshot.js +45 -0
- package/dist/api/types.js +1 -0
- package/dist/cli/args.js +7 -1
- package/dist/cli/commands.js +6 -0
- package/dist/cli/main.js +22 -10
- package/dist/git/blob.js +21 -0
- package/dist/git/diff.js +44 -2
- package/dist/git/exec.js +29 -3
- package/dist/git/lfs.js +39 -0
- package/dist/schema/cli-pin.js +18 -0
- package/dist/schema/image-diff.js +51 -0
- package/dist/schema/image.js +35 -0
- package/dist/schema/parse.js +5 -1
- package/dist/schema/skill-sync.js +90 -0
- package/dist/server/http.js +83 -197
- package/dist/ui/assets/index-BxIxxAYx.css +1 -0
- package/dist/ui/assets/index-D4bY-beP.js +1598 -0
- package/dist/ui/index.html +16 -2
- package/package.json +15 -11
- package/skills/comprehende/SKILL.md +44 -29
- package/skills/comprehende/references/example.md +21 -0
- package/skills/comprehende/references/review.schema.json +6 -1
- package/dist/cli/args.d.ts +0 -20
- package/dist/cli/args.js.map +0 -1
- package/dist/cli/commands.d.ts +0 -12
- package/dist/cli/commands.js.map +0 -1
- package/dist/cli/main.d.ts +0 -2
- package/dist/cli/main.js.map +0 -1
- package/dist/git/blame.d.ts +0 -8
- package/dist/git/blame.js.map +0 -1
- package/dist/git/diff.d.ts +0 -13
- package/dist/git/diff.js.map +0 -1
- package/dist/git/exec.d.ts +0 -10
- package/dist/git/exec.js.map +0 -1
- package/dist/git/log.d.ts +0 -8
- package/dist/git/log.js.map +0 -1
- package/dist/git/repo.d.ts +0 -7
- package/dist/git/repo.js.map +0 -1
- package/dist/git/show.d.ts +0 -2
- package/dist/git/show.js.map +0 -1
- package/dist/package-root.d.ts +0 -2
- package/dist/package-root.js.map +0 -1
- package/dist/review/coverage.d.ts +0 -20
- package/dist/review/coverage.js.map +0 -1
- package/dist/review/effort.d.ts +0 -1
- package/dist/review/effort.js +0 -17
- package/dist/review/effort.js.map +0 -1
- package/dist/review/generate.d.ts +0 -20
- package/dist/review/generate.js +0 -290
- package/dist/review/generate.js.map +0 -1
- package/dist/schema/blame-runs.d.ts +0 -14
- package/dist/schema/blame-runs.js.map +0 -1
- package/dist/schema/hunk-meta.d.ts +0 -26
- package/dist/schema/hunk-meta.js.map +0 -1
- package/dist/schema/hunk-patch.d.ts +0 -2
- package/dist/schema/hunk-patch.js +0 -49
- package/dist/schema/hunk-patch.js.map +0 -1
- package/dist/schema/identity.d.ts +0 -3
- package/dist/schema/identity.js.map +0 -1
- package/dist/schema/parse.d.ts +0 -13
- package/dist/schema/parse.js.map +0 -1
- package/dist/schema/skill-paths.d.ts +0 -7
- package/dist/schema/skill-paths.js.map +0 -1
- package/dist/schema/types.d.ts +0 -73
- package/dist/schema/types.js.map +0 -1
- package/dist/server/http.d.ts +0 -12
- package/dist/server/http.js.map +0 -1
- package/dist/ui/assets/index-BCZwzr3E.js +0 -1585
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
|
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
|
-
|
|
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`.
|
package/dist/api/live.js
ADDED
|
@@ -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
|
};
|
package/dist/cli/commands.js
CHANGED
|
@@ -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,
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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
|
|
45
|
-
|
|
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 = () => {
|
package/dist/git/blob.js
ADDED
|
@@ -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
|
+
}
|