review-workspace 1.10.2 → 1.12.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 +1 -1
- package/dist/bundle/annotation-fields.js +18 -0
- package/dist/bundle/carry-forward.js +7 -4
- package/dist/bundle/format-diagnostic.js +33 -0
- package/dist/bundle/live-head.js +34 -0
- package/dist/bundle/proof-links.js +71 -0
- package/dist/bundle/publish-with-handoff.js +1 -2
- package/dist/bundle/publish.js +1 -1
- package/dist/bundle/resolution.js +4 -0
- package/dist/bundle/resolve-diagnostics.js +41 -1
- package/dist/bundle/round.js +5 -2
- package/dist/bundle/validate-bundle.js +1 -1
- package/dist/bundle/watcher.js +38 -0
- package/dist/cli.js +16 -4
- package/dist/handoff/build-handoff-markdown.js +20 -11
- package/dist/handoff/ensure-git-exclude.js +34 -0
- package/dist/handoff/export-handoff.js +12 -12
- package/dist/patch/parse.js +7 -1
- package/dist/questions/comment-target.js +36 -0
- package/dist/questions/questions-log.js +11 -3
- package/dist/renderer/open-gaps.js +13 -0
- package/dist/renderer/render.js +47 -5
- package/dist/schema/types.js +2 -1
- package/dist/schema/validate.js +1 -1
- package/dist/security/repo-file.js +45 -0
- package/dist/server/create-server.js +53 -14
- package/dist/server/write-token.js +27 -0
- package/package.json +1 -1
- package/schemas/review-document.schema.json +42 -2
- package/ui-dist/_app/index-BEfydsCd.js +119 -0
- package/ui-dist/_app/index-D8IT_AV4.css +1 -0
- package/ui-dist/index.html +2 -2
- package/ui-dist/_app/index-BZCY_1od.js +0 -118
- package/ui-dist/_app/index-Cqnbk3t0.css +0 -1
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ To view an already-scaffolded bundle:
|
|
|
30
30
|
npx review-workspace serve .bundles/<name> --port 4317
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Opens the UI at `http://127.0.0.1:4317` (prints a write token too — needed only to raise/answer Questions, not to view). One process, no separate dev server.
|
|
33
|
+
Opens the UI at `http://127.0.0.1:4317` (prints a write token too — needed only to raise/answer Questions, not to view). The token is saved in the bundle as `.write-token` and stays the same after a restart. Use `serve --rotate-token` to make a new one. One process, no separate dev server.
|
|
34
34
|
|
|
35
35
|
## Addressing review feedback in the target repo
|
|
36
36
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const FIRST_VERSION_REQUIRING_NOTE_FIELDS = 2;
|
|
2
|
+
const VERDICTS_NEEDING_SUGGESTION = ['wrong', 'missing'];
|
|
3
|
+
export function checkAnnotationFields(document) {
|
|
4
|
+
if (document.schemaVersion < FIRST_VERSION_REQUIRING_NOTE_FIELDS)
|
|
5
|
+
return [];
|
|
6
|
+
const diagnostics = [];
|
|
7
|
+
for (const annotation of document.annotations ?? []) {
|
|
8
|
+
if (!annotation.verdict) {
|
|
9
|
+
diagnostics.push({ kind: 'missing-verdict', annotationId: annotation.id });
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
const needsSuggestion = VERDICTS_NEEDING_SUGGESTION.includes(annotation.verdict);
|
|
13
|
+
if (needsSuggestion && !annotation.suggestion?.trim()) {
|
|
14
|
+
diagnostics.push({ kind: 'missing-suggestion', annotationId: annotation.id, verdict: annotation.verdict });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return diagnostics;
|
|
18
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { parsePatch } from "../patch/parse.js";
|
|
4
|
-
import { carryForwardComment, readQuestions } from "../questions/questions-log.js";
|
|
5
|
-
import { evaluateResolution } from "./resolution.js";
|
|
4
|
+
import { carryForwardComment, isOpenChangeRequest, readQuestions } from "../questions/questions-log.js";
|
|
5
|
+
import { evaluateResolution, isCodeComment } from "./resolution.js";
|
|
6
6
|
import { incrementalTouchedPaths } from "./incremental-patch.js";
|
|
7
7
|
export function createCaches() {
|
|
8
8
|
return { chain: new Map(), patch: new Map(), reviewDocument: new Map() };
|
|
@@ -30,7 +30,7 @@ function readReviewDocument(bundlePath, caches) {
|
|
|
30
30
|
return caches.reviewDocument.get(bundlePath);
|
|
31
31
|
}
|
|
32
32
|
function openChangeRequests(bundlePath) {
|
|
33
|
-
return readQuestions(bundlePath).filter(
|
|
33
|
+
return readQuestions(bundlePath).filter(isOpenChangeRequest);
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
36
|
* Shared shape behind every chain-walking function below: a round-1 bundle (no
|
|
@@ -68,6 +68,7 @@ export function computeCarriedResolutions(bundlePath, caches = createCaches()) {
|
|
|
68
68
|
const currentPatch = readPatch(bundlePath, caches);
|
|
69
69
|
return readQuestions(bundlePath)
|
|
70
70
|
.filter((c) => carriedIds.has(c.id))
|
|
71
|
+
.filter(isCodeComment)
|
|
71
72
|
.map((comment) => ({ comment, resolution: evaluateResolution(comment, previousPatch, currentPatch) }));
|
|
72
73
|
});
|
|
73
74
|
}
|
|
@@ -99,6 +100,8 @@ export function listComments(bundlePath, caches = createCaches()) {
|
|
|
99
100
|
}
|
|
100
101
|
function targetKey(target) {
|
|
101
102
|
switch (target.type) {
|
|
103
|
+
case 'general':
|
|
104
|
+
return 'general';
|
|
102
105
|
case 'file':
|
|
103
106
|
return `file:${target.path}`;
|
|
104
107
|
case 'binary':
|
|
@@ -128,7 +131,7 @@ export function collectCarriedReviewContent(bundlePath, ownDocument, caches = cr
|
|
|
128
131
|
const ownTargetKeys = new Set((ownDocument.annotations ?? []).map((a) => targetKey(a.target)));
|
|
129
132
|
const ownGroupIds = new Set((ownDocument.behavioralGroups ?? []).map((g) => g.id));
|
|
130
133
|
return {
|
|
131
|
-
annotations: previousAll.annotations.filter((a) => !touchedPaths.has(a.target.path) && !ownTargetKeys.has(targetKey(a.target))),
|
|
134
|
+
annotations: previousAll.annotations.filter((a) => a.target.type !== 'general' && !touchedPaths.has(a.target.path) && !ownTargetKeys.has(targetKey(a.target))),
|
|
132
135
|
behavioralGroups: previousAll.behavioralGroups.filter((g) => !ownGroupIds.has(g.id) && !(g.targets ?? []).some((t) => touchedPaths.has(t.path))),
|
|
133
136
|
};
|
|
134
137
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
function describeDiagnostic(d) {
|
|
2
|
+
switch (d.kind) {
|
|
3
|
+
case 'stale-line-target':
|
|
4
|
+
return `Stale annotation: ${d.detail}`;
|
|
5
|
+
case 'invalid-field':
|
|
6
|
+
return `Invalid field (${d.instancePath}): ${d.message}`;
|
|
7
|
+
case 'unresolved-target':
|
|
8
|
+
return `Unresolved ${d.targetType} target: ${d.detail}`;
|
|
9
|
+
case 'missing-asset':
|
|
10
|
+
return `Missing asset: ${d.assetPath}`;
|
|
11
|
+
case 'unsafe-asset-path':
|
|
12
|
+
return `Unsafe asset path: ${d.assetPath}`;
|
|
13
|
+
case 'disallowed-asset-type':
|
|
14
|
+
return `Disallowed asset type: ${d.assetPath}`;
|
|
15
|
+
case 'asset-too-large':
|
|
16
|
+
return `Asset too large (${d.bytes} bytes): ${d.assetPath}`;
|
|
17
|
+
case 'image-without-comparison':
|
|
18
|
+
return `Changed image has no base and head asset: ${d.path}`;
|
|
19
|
+
case 'dangling-answer':
|
|
20
|
+
return `Answer ${d.answerId} references unknown Question ${d.questionId}`;
|
|
21
|
+
case 'unanchored-verification-gap':
|
|
22
|
+
return `Verification gap ${d.verificationId} has no concrete anchor: ${d.detail}`;
|
|
23
|
+
case 'missing-verdict':
|
|
24
|
+
return `Note ${d.annotationId} has no verdict. Set it to intended, missing, wrong, extra or unclear.`;
|
|
25
|
+
case 'missing-suggestion':
|
|
26
|
+
return `Note ${d.annotationId} has the verdict ${d.verdict} but no suggestion. Say what to change.`;
|
|
27
|
+
case 'invalid-proof-link':
|
|
28
|
+
return `Proof link in evidence ${d.evidenceId} is broken: ${d.detail}`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function formatDiagnostic(d) {
|
|
32
|
+
return `${d.kind}: ${describeDiagnostic(d)}`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export const LIVE_HEAD_FILE = 'live-head.json';
|
|
4
|
+
function parseLiveHead(value) {
|
|
5
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
6
|
+
return `${LIVE_HEAD_FILE} must be a JSON object`;
|
|
7
|
+
const { head, checkedAt } = value;
|
|
8
|
+
if (typeof head !== 'string' || head === '')
|
|
9
|
+
return 'head must be a non-empty string';
|
|
10
|
+
if (typeof checkedAt !== 'string' || Number.isNaN(Date.parse(checkedAt)))
|
|
11
|
+
return 'checkedAt must be an ISO date string';
|
|
12
|
+
return { head, checkedAt };
|
|
13
|
+
}
|
|
14
|
+
export function readLiveHead(bundlePath) {
|
|
15
|
+
const path = join(bundlePath, LIVE_HEAD_FILE);
|
|
16
|
+
if (!existsSync(path))
|
|
17
|
+
return { status: 'absent' };
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
return { status: 'invalid', message: `${LIVE_HEAD_FILE} is not valid JSON: ${err.message}` };
|
|
24
|
+
}
|
|
25
|
+
const parsed = parseLiveHead(raw);
|
|
26
|
+
return typeof parsed === 'string' ? { status: 'invalid', message: parsed } : { status: 'ok', liveHead: parsed };
|
|
27
|
+
}
|
|
28
|
+
export function bundleStaleness(comparisonHead, liveHead, round) {
|
|
29
|
+
return { stale: liveHead !== undefined && liveHead.head !== comparisonHead, round };
|
|
30
|
+
}
|
|
31
|
+
export function buildLiveHeadStatus(read, comparisonHead, round) {
|
|
32
|
+
const liveHead = read.status === 'ok' ? read.liveHead : undefined;
|
|
33
|
+
return { ...read, recordedHead: comparisonHead, ...bundleStaleness(comparisonHead, liveHead, round) };
|
|
34
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ALLOWED_ASSET_EXTENSIONS } from "../security/asset-path.js";
|
|
2
|
+
function isImagePath(id) {
|
|
3
|
+
const lowerId = id.toLowerCase();
|
|
4
|
+
return ALLOWED_ASSET_EXTENSIONS.some((ext) => lowerId.endsWith(ext));
|
|
5
|
+
}
|
|
6
|
+
function hunkIndexOf(target, patch) {
|
|
7
|
+
if (target.type === 'hunk')
|
|
8
|
+
return target.hunkIndex;
|
|
9
|
+
if (target.type !== 'line')
|
|
10
|
+
return undefined;
|
|
11
|
+
const file = patch.files.find((f) => f.path === target.path);
|
|
12
|
+
const index = file?.hunks.findIndex((h) => h.lines.some((l) => (target.side === 'base' ? l.oldLine === target.line : l.newLine === target.line)));
|
|
13
|
+
return index === undefined || index < 0 ? undefined : index;
|
|
14
|
+
}
|
|
15
|
+
function codeRefFromAnnotation(annotation, patch) {
|
|
16
|
+
const { target } = annotation;
|
|
17
|
+
if (target.type === 'general' || target.type === 'binary')
|
|
18
|
+
return undefined;
|
|
19
|
+
const hunkIndex = hunkIndexOf(target, patch);
|
|
20
|
+
return { path: target.path, ...(hunkIndex === undefined ? {} : { hunkIndex }), annotationId: annotation.id };
|
|
21
|
+
}
|
|
22
|
+
function codeRefFromId(id, annotationsById, patch) {
|
|
23
|
+
const annotation = annotationsById.get(id);
|
|
24
|
+
if (annotation)
|
|
25
|
+
return codeRefFromAnnotation(annotation, patch);
|
|
26
|
+
const file = patch.files.find((f) => f.path === id);
|
|
27
|
+
return file && !file.binary ? { path: id } : undefined;
|
|
28
|
+
}
|
|
29
|
+
function isIdOfImageSide(id, annotationsById) {
|
|
30
|
+
const annotation = annotationsById.get(id);
|
|
31
|
+
return annotation?.target.type === 'binary';
|
|
32
|
+
}
|
|
33
|
+
function findLinksForEvidence(evidence, annotationsById, patch) {
|
|
34
|
+
const ids = evidence.targetIds ?? [];
|
|
35
|
+
const imageIds = ids.filter(isImagePath);
|
|
36
|
+
const otherIds = ids.filter((id) => !isImagePath(id) && !isIdOfImageSide(id, annotationsById));
|
|
37
|
+
if (imageIds.length === 0 || otherIds.length === 0)
|
|
38
|
+
return { links: [], diagnostics: [] };
|
|
39
|
+
if (!evidence.assetPath) {
|
|
40
|
+
const detail = 'the evidence has no assetPath, so there is no image to show';
|
|
41
|
+
return { links: [], diagnostics: [{ kind: 'invalid-proof-link', evidenceId: evidence.id, detail }] };
|
|
42
|
+
}
|
|
43
|
+
const diagnostics = [];
|
|
44
|
+
const changedImagePaths = new Set(patch.files.filter((f) => f.binary).map((f) => f.path));
|
|
45
|
+
const images = imageIds.filter((imagePath) => {
|
|
46
|
+
const exists = changedImagePaths.has(imagePath);
|
|
47
|
+
if (!exists) {
|
|
48
|
+
diagnostics.push({ kind: 'invalid-proof-link', evidenceId: evidence.id, detail: `image ${imagePath} is not a changed image in the patch` });
|
|
49
|
+
}
|
|
50
|
+
return exists;
|
|
51
|
+
});
|
|
52
|
+
const codeRefs = otherIds.flatMap((id) => codeRefFromId(id, annotationsById, patch) ?? []);
|
|
53
|
+
if (codeRefs.length === 0) {
|
|
54
|
+
const named = otherIds.join(', ');
|
|
55
|
+
diagnostics.push({
|
|
56
|
+
kind: 'invalid-proof-link',
|
|
57
|
+
evidenceId: evidence.id,
|
|
58
|
+
detail: `no code Target found for ${named} (use an Annotation id or a changed file path)`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const links = images.flatMap((imagePath) => codeRefs.map((code) => ({ evidenceId: evidence.id, imagePath, code })));
|
|
62
|
+
return { links, diagnostics };
|
|
63
|
+
}
|
|
64
|
+
export function findProofLinks(document, patch) {
|
|
65
|
+
const annotationsById = new Map((document.annotations ?? []).map((a) => [a.id, a]));
|
|
66
|
+
const results = (document.evidence ?? []).map((e) => findLinksForEvidence(e, annotationsById, patch));
|
|
67
|
+
return {
|
|
68
|
+
links: results.flatMap((r) => r.links),
|
|
69
|
+
diagnostics: results.flatMap((r) => r.diagnostics),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -24,8 +24,7 @@ export async function publishBundleAndExportHandoff(bundlePath, options) {
|
|
|
24
24
|
round,
|
|
25
25
|
comments: readQuestions(bundlePath),
|
|
26
26
|
annotations: document.annotations ?? [],
|
|
27
|
-
|
|
28
|
-
repoPathCachePath: options.repoPathCachePath,
|
|
27
|
+
repoPath: options.repoPath,
|
|
29
28
|
});
|
|
30
29
|
return { ...result, handoff };
|
|
31
30
|
}
|
package/dist/bundle/publish.js
CHANGED
|
@@ -20,5 +20,5 @@ export function publishBundle(bundlePath, limits = DEFAULT_LIMITS) {
|
|
|
20
20
|
const tmpPath = join(bundlePath, `.review.json.tmp-${process.pid}-${Date.now()}`);
|
|
21
21
|
writeFileSync(tmpPath, readFileSync(nextPath, 'utf-8'));
|
|
22
22
|
renameSync(tmpPath, reviewPath);
|
|
23
|
-
return { ok: true };
|
|
23
|
+
return { ok: true, diagnostics: result.diagnostics };
|
|
24
24
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { resolveTarget } from "./resolve-diagnostics.js";
|
|
2
2
|
import { incrementalHunks } from "./incremental-patch.js";
|
|
3
|
+
/** A general Comment has no place in the patch, so it has no Resolution. */
|
|
4
|
+
export function isCodeComment(comment) {
|
|
5
|
+
return comment.target?.type !== 'general';
|
|
6
|
+
}
|
|
3
7
|
function targetHunkIndex(target, patch) {
|
|
4
8
|
const file = patch.files.find((f) => f.path === target.path);
|
|
5
9
|
if (!file)
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs';
|
|
2
2
|
import { validateReviewDocumentSchema } from "../schema/validate.js";
|
|
3
|
-
import { resolveAssetPath } from "../security/asset-path.js";
|
|
3
|
+
import { ALLOWED_ASSET_EXTENSIONS, resolveAssetPath } from "../security/asset-path.js";
|
|
4
4
|
import { DEFAULT_LIMITS } from "../security/limits.js";
|
|
5
5
|
import { readQuestions } from "../questions/questions-log.js";
|
|
6
|
+
import { checkAnnotationFields } from "./annotation-fields.js";
|
|
7
|
+
import { findProofLinks } from "./proof-links.js";
|
|
6
8
|
function findFile(patch, path) {
|
|
7
9
|
return patch.files.find((f) => f.path === path);
|
|
8
10
|
}
|
|
@@ -24,6 +26,8 @@ function checkAssetPath(bundlePath, assetPath, limits) {
|
|
|
24
26
|
}
|
|
25
27
|
/** Resolves a Target against a patch, returning the Diagnostic that explains why it's unresolved/stale, or undefined when it resolves cleanly. */
|
|
26
28
|
export function resolveTarget(target, patch) {
|
|
29
|
+
if (target.type === 'general')
|
|
30
|
+
return undefined;
|
|
27
31
|
const file = findFile(patch, target.path);
|
|
28
32
|
if (target.type === 'file') {
|
|
29
33
|
if (!file)
|
|
@@ -81,6 +85,34 @@ function isConcreteAnchor(id, annotationIds, evidenceById, filePaths) {
|
|
|
81
85
|
return false;
|
|
82
86
|
return (evidence.targetIds ?? []).some((innerId) => annotationIds.has(innerId) || filePaths.has(innerId));
|
|
83
87
|
}
|
|
88
|
+
function intentVisualAssetPaths(document) {
|
|
89
|
+
return (document.intent?.visuals ?? []).flatMap((v) => (v.baseAssetPath ? [v.assetPath, v.baseAssetPath] : [v.assetPath]));
|
|
90
|
+
}
|
|
91
|
+
function isImagePath(path) {
|
|
92
|
+
const lowerPath = path.toLowerCase();
|
|
93
|
+
return ALLOWED_ASSET_EXTENSIONS.some((ext) => lowerPath.endsWith(ext));
|
|
94
|
+
}
|
|
95
|
+
function changedImagePaths(document, patch) {
|
|
96
|
+
const targets = [
|
|
97
|
+
...(document.behavioralGroups ?? []).flatMap((g) => g.targets ?? []),
|
|
98
|
+
...(document.annotations ?? []).map((a) => a.target),
|
|
99
|
+
];
|
|
100
|
+
const paths = [
|
|
101
|
+
...patch.files.filter((f) => f.binary).map((f) => f.path),
|
|
102
|
+
...targets.flatMap((t) => (t.type === 'binary' ? [t.path] : [])),
|
|
103
|
+
];
|
|
104
|
+
const addedPaths = new Set(patch.files.filter((f) => f.added).map((f) => f.path));
|
|
105
|
+
return [...new Set(paths.filter(isImagePath))].filter((path) => !addedPaths.has(path));
|
|
106
|
+
}
|
|
107
|
+
function findImagesWithoutComparison(document, patch) {
|
|
108
|
+
const pairedEvidence = (document.evidence ?? []).filter((e) => e.assetPath && e.baseAssetPath);
|
|
109
|
+
return changedImagePaths(document, patch)
|
|
110
|
+
.filter((path) => {
|
|
111
|
+
const annotationIds = (document.annotations ?? []).filter((a) => a.target.type !== 'general' && a.target.path === path).map((a) => a.id);
|
|
112
|
+
return !pairedEvidence.some((e) => (e.targetIds ?? []).some((id) => id === path || annotationIds.includes(id)));
|
|
113
|
+
})
|
|
114
|
+
.map((path) => ({ kind: 'image-without-comparison', path }));
|
|
115
|
+
}
|
|
84
116
|
export function collectDiagnostics(document, patch, bundlePath, limits = DEFAULT_LIMITS) {
|
|
85
117
|
const diagnostics = [];
|
|
86
118
|
const schemaResult = validateReviewDocumentSchema(document);
|
|
@@ -115,6 +147,14 @@ export function collectDiagnostics(document, patch, bundlePath, limits = DEFAULT
|
|
|
115
147
|
diagnostics.push(diagnostic);
|
|
116
148
|
}
|
|
117
149
|
}
|
|
150
|
+
for (const assetPath of intentVisualAssetPaths(document)) {
|
|
151
|
+
const diagnostic = checkAssetPath(bundlePath, assetPath, limits);
|
|
152
|
+
if (diagnostic)
|
|
153
|
+
diagnostics.push(diagnostic);
|
|
154
|
+
}
|
|
155
|
+
diagnostics.push(...checkAnnotationFields(document));
|
|
156
|
+
diagnostics.push(...findProofLinks(document, patch).diagnostics);
|
|
157
|
+
diagnostics.push(...findImagesWithoutComparison(document, patch));
|
|
118
158
|
if (document.answers?.length) {
|
|
119
159
|
const questionIds = new Set(readQuestions(bundlePath).map((q) => q.id));
|
|
120
160
|
for (const answer of document.answers) {
|
package/dist/bundle/round.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { carryForwardChain } from "./carry-forward.js";
|
|
3
4
|
/**
|
|
4
5
|
* A stable cross-round identity for one MR: repo slug + number, e.g.
|
|
5
|
-
* "
|
|
6
|
+
* "group/project!367".
|
|
6
7
|
*/
|
|
7
8
|
export function mrKeyFor(comparison) {
|
|
8
9
|
if (!comparison.repository || !comparison.number) {
|
|
@@ -47,7 +48,8 @@ export function checkRound(bundlePath, liveHead) {
|
|
|
47
48
|
* Scaffolds a new sibling round bundle for an MR whose live head has moved
|
|
48
49
|
* past what `bundlePath` recorded. Never mutates `bundlePath` (ADR 0002) —
|
|
49
50
|
* only creates the new `<name>-r{N}` directory alongside it, with its own
|
|
50
|
-
* Comparison, Unified Patch,
|
|
51
|
+
* Comparison, Unified Patch, a `chain.json` linking it back, and a
|
|
52
|
+
* `questions.jsonl` holding the previous round's open change-requests.
|
|
51
53
|
*/
|
|
52
54
|
export function scaffoldNextRound(bundlePath, liveHead, options) {
|
|
53
55
|
const document = readReviewDocument(bundlePath);
|
|
@@ -73,5 +75,6 @@ export function scaffoldNextRound(bundlePath, liveHead, options) {
|
|
|
73
75
|
comparison: { ...document.comparison, head: liveHead },
|
|
74
76
|
}, null, 2));
|
|
75
77
|
writeFileSync(join(newBundlePath, 'chain.json'), JSON.stringify(chain, null, 2));
|
|
78
|
+
carryForwardChain(newBundlePath);
|
|
76
79
|
return { bundlePath: newBundlePath, chain };
|
|
77
80
|
}
|
|
@@ -4,7 +4,7 @@ import { parsePatch } from "../patch/parse.js";
|
|
|
4
4
|
import { PatchParseError } from "../patch/types.js";
|
|
5
5
|
import { collectDiagnostics } from "./resolve-diagnostics.js";
|
|
6
6
|
import { DEFAULT_LIMITS } from "../security/limits.js";
|
|
7
|
-
export const SUPPORTED_SCHEMA_VERSIONS = [1];
|
|
7
|
+
export const SUPPORTED_SCHEMA_VERSIONS = [1, 2];
|
|
8
8
|
function hasComparisonIdentity(document) {
|
|
9
9
|
if (typeof document !== 'object' || document === null)
|
|
10
10
|
return false;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export const WATCHER_FILE = 'watcher.json';
|
|
4
|
+
function isCount(value) {
|
|
5
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
6
|
+
}
|
|
7
|
+
function parseWatcher(value) {
|
|
8
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
9
|
+
return `${WATCHER_FILE} must be a JSON object`;
|
|
10
|
+
const { enabled, cap, used, current = null, updatedAt } = value;
|
|
11
|
+
if (typeof enabled !== 'boolean')
|
|
12
|
+
return 'enabled must be true or false';
|
|
13
|
+
if (!isCount(cap))
|
|
14
|
+
return 'cap must be a whole number of 0 or more';
|
|
15
|
+
if (!isCount(used))
|
|
16
|
+
return 'used must be a whole number of 0 or more';
|
|
17
|
+
if (current !== null && typeof current !== 'string')
|
|
18
|
+
return 'current must be a Question id or null';
|
|
19
|
+
if (updatedAt === undefined)
|
|
20
|
+
return { enabled, cap, used, current };
|
|
21
|
+
if (typeof updatedAt !== 'string' || Number.isNaN(Date.parse(updatedAt)))
|
|
22
|
+
return 'updatedAt must be an ISO date-time string';
|
|
23
|
+
return { enabled, cap, used, current, updatedAt };
|
|
24
|
+
}
|
|
25
|
+
export function readWatcher(bundlePath) {
|
|
26
|
+
const path = join(bundlePath, WATCHER_FILE);
|
|
27
|
+
if (!existsSync(path))
|
|
28
|
+
return { status: 'absent' };
|
|
29
|
+
let raw;
|
|
30
|
+
try {
|
|
31
|
+
raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
return { status: 'invalid', message: `${WATCHER_FILE} is not valid JSON: ${err.message}` };
|
|
35
|
+
}
|
|
36
|
+
const parsed = parseWatcher(raw);
|
|
37
|
+
return typeof parsed === 'string' ? { status: 'invalid', message: parsed } : { status: 'ok', watcher: parsed };
|
|
38
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { formatDiagnostic } from "./bundle/format-diagnostic.js";
|
|
3
4
|
import { validateBundle } from "./bundle/validate-bundle.js";
|
|
4
5
|
import { publishBundleAndExportHandoff } from "./bundle/publish-with-handoff.js";
|
|
5
6
|
import { checkRound, scaffoldNextRound } from "./bundle/round.js";
|
|
6
|
-
import { askForRepoPathOnStdin } from "./handoff/ask-repo-path.js";
|
|
7
7
|
import { carryForwardChain } from "./bundle/carry-forward.js";
|
|
8
8
|
import { startReviewServer } from "./server/create-server.js";
|
|
9
9
|
const [, , command, bundlePath, ...rest] = process.argv;
|
|
@@ -16,7 +16,7 @@ function flagValue(name) {
|
|
|
16
16
|
return index === -1 ? undefined : rest[index + 1];
|
|
17
17
|
}
|
|
18
18
|
if (!command || !bundlePath) {
|
|
19
|
-
fail('Usage: review-workspace <open|publish|serve|round> <bundlePath> [--port N] [--live-head SHA --patch FILE]');
|
|
19
|
+
fail('Usage: review-workspace <open|publish|serve|round> <bundlePath> [--port N] [--rotate-token] [--repo PATH] [--live-head SHA --patch FILE]');
|
|
20
20
|
}
|
|
21
21
|
if (command === 'open') {
|
|
22
22
|
carryForwardChain(bundlePath);
|
|
@@ -27,17 +27,29 @@ if (command === 'open') {
|
|
|
27
27
|
console.log('Bundle is valid.');
|
|
28
28
|
if (result.diagnostics?.length) {
|
|
29
29
|
console.log(`${result.diagnostics.length} diagnostic(s) found.`);
|
|
30
|
+
for (const diagnostic of result.diagnostics) {
|
|
31
|
+
console.log(` ${formatDiagnostic(diagnostic)}`);
|
|
32
|
+
}
|
|
30
33
|
}
|
|
31
34
|
}
|
|
32
35
|
else if (command === 'publish') {
|
|
33
|
-
const result = await publishBundleAndExportHandoff(bundlePath, {
|
|
36
|
+
const result = await publishBundleAndExportHandoff(bundlePath, { repoPath: flagValue('--repo') });
|
|
34
37
|
if (!result.ok) {
|
|
35
38
|
fail(`Publish rejected: ${result.blockingReason} — ${result.message}`);
|
|
36
39
|
}
|
|
37
40
|
console.log('Published.');
|
|
41
|
+
for (const diagnostic of result.diagnostics ?? []) {
|
|
42
|
+
console.log(` ${formatDiagnostic(diagnostic)}`);
|
|
43
|
+
}
|
|
38
44
|
if (result.handoff?.written) {
|
|
39
45
|
console.log(`Hand-off file written: ${result.handoff.filePath}`);
|
|
40
46
|
}
|
|
47
|
+
else if (result.handoff?.skipped === 'no-repo-path') {
|
|
48
|
+
console.log('Hand-off skipped: pass --repo <path> to export the open change-requests.');
|
|
49
|
+
}
|
|
50
|
+
else if (result.handoff?.skipped === 'not-a-git-repo') {
|
|
51
|
+
console.log('Hand-off skipped: the --repo path is not a git repository.');
|
|
52
|
+
}
|
|
41
53
|
}
|
|
42
54
|
else if (command === 'round') {
|
|
43
55
|
const liveHead = flagValue('--live-head');
|
|
@@ -57,7 +69,7 @@ else if (command === 'round') {
|
|
|
57
69
|
}
|
|
58
70
|
else if (command === 'serve') {
|
|
59
71
|
const port = Number(flagValue('--port') ?? 4317);
|
|
60
|
-
const handle = await startReviewServer(bundlePath, { port });
|
|
72
|
+
const handle = await startReviewServer(bundlePath, { port, rotateToken: rest.includes('--rotate-token'), repoPath: flagValue('--repo') });
|
|
61
73
|
console.log(JSON.stringify({ port: handle.port, writeToken: handle.writeToken, url: `http://127.0.0.1:${handle.port}` }));
|
|
62
74
|
}
|
|
63
75
|
else {
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { isOpenChangeRequest } from "../questions/questions-log.js";
|
|
3
|
+
const NO_TARGET_GROUP = '(general — no Target)';
|
|
4
|
+
const GENERAL_TARGET_GROUP = 'General';
|
|
5
|
+
function groupKey(target) {
|
|
6
|
+
if (!target)
|
|
7
|
+
return NO_TARGET_GROUP;
|
|
8
|
+
return target.type === 'general' ? GENERAL_TARGET_GROUP : target.path;
|
|
5
9
|
}
|
|
6
10
|
function targetKey(target) {
|
|
7
11
|
switch (target.type) {
|
|
12
|
+
case 'general':
|
|
13
|
+
return 'general';
|
|
8
14
|
case 'file':
|
|
9
15
|
return `file:${target.path}`;
|
|
10
16
|
case 'binary':
|
|
@@ -25,6 +31,8 @@ function targetsEqual(a, b) {
|
|
|
25
31
|
}
|
|
26
32
|
function describeTarget(target) {
|
|
27
33
|
switch (target.type) {
|
|
34
|
+
case 'general':
|
|
35
|
+
return 'General';
|
|
28
36
|
case 'file':
|
|
29
37
|
return `File: ${target.path}`;
|
|
30
38
|
case 'hunk':
|
|
@@ -36,25 +44,26 @@ function describeTarget(target) {
|
|
|
36
44
|
}
|
|
37
45
|
}
|
|
38
46
|
function openChangeRequests(comments) {
|
|
39
|
-
return comments.filter(
|
|
47
|
+
return comments.filter(isOpenChangeRequest);
|
|
40
48
|
}
|
|
41
49
|
function groupByFile(comments) {
|
|
42
50
|
const byFile = new Map();
|
|
43
51
|
for (const comment of comments) {
|
|
44
|
-
const key =
|
|
52
|
+
const key = groupKey(comment.target);
|
|
45
53
|
const group = byFile.get(key) ?? [];
|
|
46
54
|
group.push(comment);
|
|
47
55
|
byFile.set(key, group);
|
|
48
56
|
}
|
|
49
57
|
return byFile;
|
|
50
58
|
}
|
|
59
|
+
function groupRank(key) {
|
|
60
|
+
if (key === NO_TARGET_GROUP)
|
|
61
|
+
return 2;
|
|
62
|
+
return key === GENERAL_TARGET_GROUP ? 1 : 0;
|
|
63
|
+
}
|
|
51
64
|
function orderedFileKeys(byFile) {
|
|
52
65
|
return [...byFile.keys()].sort((a, b) => {
|
|
53
|
-
|
|
54
|
-
return 1;
|
|
55
|
-
if (b === GENERAL_GROUP)
|
|
56
|
-
return -1;
|
|
57
|
-
return a.localeCompare(b);
|
|
66
|
+
return groupRank(a) - groupRank(b) || a.localeCompare(b);
|
|
58
67
|
});
|
|
59
68
|
}
|
|
60
69
|
function metadataBlock(input) {
|
|
@@ -95,7 +104,7 @@ export function buildHandoffMarkdown(input) {
|
|
|
95
104
|
lines.push('');
|
|
96
105
|
lines.push(comment.body);
|
|
97
106
|
lines.push('');
|
|
98
|
-
const relatedAnnotations = comment.target
|
|
107
|
+
const relatedAnnotations = comment.target && comment.target.type !== 'general'
|
|
99
108
|
? input.annotations.filter((a) => targetsEqual(a.target, comment.target))
|
|
100
109
|
: [];
|
|
101
110
|
for (const annotation of relatedAnnotations) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
const ENTRY = '.review-feedback/';
|
|
5
|
+
function resolveExcludePath(repoPath) {
|
|
6
|
+
try {
|
|
7
|
+
const output = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], {
|
|
8
|
+
cwd: repoPath,
|
|
9
|
+
encoding: 'utf-8',
|
|
10
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
11
|
+
});
|
|
12
|
+
return resolve(repoPath, output.trim());
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Lists `.review-feedback/` in the repo's local `.git/info/exclude`, so the
|
|
20
|
+
* hand-off file never shows up as a change and the tracked `.gitignore` is
|
|
21
|
+
* never edited. Returns false when `repoPath` is not a git repo.
|
|
22
|
+
*/
|
|
23
|
+
export function ensureGitExcludesHandoff(repoPath) {
|
|
24
|
+
const excludePath = resolveExcludePath(repoPath);
|
|
25
|
+
if (!excludePath)
|
|
26
|
+
return false;
|
|
27
|
+
const content = existsSync(excludePath) ? readFileSync(excludePath, 'utf-8') : '';
|
|
28
|
+
if (content.split('\n').some((line) => line.trim() === ENTRY))
|
|
29
|
+
return true;
|
|
30
|
+
const withTrailingNewline = content === '' || content.endsWith('\n') ? content : `${content}\n`;
|
|
31
|
+
mkdirSync(dirname(excludePath), { recursive: true });
|
|
32
|
+
writeFileSync(excludePath, `${withTrailingNewline}${ENTRY}\n`);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { isOpenChangeRequest } from "../questions/questions-log.js";
|
|
3
4
|
import { buildHandoffMarkdown } from "./build-handoff-markdown.js";
|
|
4
|
-
import {
|
|
5
|
-
import { DEFAULT_REPO_PATH_CACHE_PATH, resolveRepoPath } from "./repo-path-cache.js";
|
|
5
|
+
import { ensureGitExcludesHandoff } from "./ensure-git-exclude.js";
|
|
6
6
|
/**
|
|
7
7
|
* Exports open change-request Comments as a self-describing hand-off
|
|
8
|
-
* Markdown file into the target repo's local working tree, and
|
|
9
|
-
* `.review-feedback/`
|
|
10
|
-
* change-requests — this is what makes it safe to call after
|
|
8
|
+
* Markdown file into the target repo's local working tree, and lists
|
|
9
|
+
* `.review-feedback/` in that repo's `.git/info/exclude`. A no-op when there
|
|
10
|
+
* are no open change-requests — this is what makes it safe to call after
|
|
11
|
+
* every publish. Never prompts: without a usable repo path it skips.
|
|
11
12
|
*/
|
|
12
13
|
export async function exportHandoff(options) {
|
|
13
|
-
const { bundlePath, comparison, round, comments, annotations,
|
|
14
|
-
const hasOpenChangeRequest = comments.some(
|
|
14
|
+
const { bundlePath, comparison, round, comments, annotations, repoPath } = options;
|
|
15
|
+
const hasOpenChangeRequest = comments.some(isOpenChangeRequest);
|
|
15
16
|
if (!hasOpenChangeRequest)
|
|
16
17
|
return { written: false };
|
|
17
|
-
if (!
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
if (!repoPath)
|
|
19
|
+
return { written: false, skipped: 'no-repo-path' };
|
|
20
|
+
if (!ensureGitExcludesHandoff(repoPath))
|
|
21
|
+
return { written: false, skipped: 'not-a-git-repo' };
|
|
21
22
|
const markdown = buildHandoffMarkdown({ bundlePath, comparison, round, comments, annotations });
|
|
22
23
|
const feedbackDir = join(repoPath, '.review-feedback');
|
|
23
24
|
mkdirSync(feedbackDir, { recursive: true });
|
|
24
25
|
const filePath = join(feedbackDir, `${comparison.number}-round${round}.md`);
|
|
25
26
|
writeFileSync(filePath, markdown);
|
|
26
|
-
ensureGitignoreExcludesHandoff(repoPath);
|
|
27
27
|
return { written: true, filePath, repoPath };
|
|
28
28
|
}
|
package/dist/patch/parse.js
CHANGED
|
@@ -2,7 +2,9 @@ import { PatchParseError } from "./types.js";
|
|
|
2
2
|
const GIT_FILE_HEADER = /^diff --git a\/(.+) b\/(.+)$/;
|
|
3
3
|
const OLD_FILE_MARKER = /^--- (.+)$/;
|
|
4
4
|
const NEW_FILE_MARKER = /^\+\+\+ (.+)$/;
|
|
5
|
+
const NO_BASE_PATH = '/dev/null';
|
|
5
6
|
const BINARY_MARKER = /^Binary files (?:a\/)?(.+) and (?:b\/)?(.+) differ$/;
|
|
7
|
+
const EXTENDED_HEADER = /^(?:index |new file mode |deleted file mode |old mode |new mode |similarity index |rename from |rename to )/;
|
|
6
8
|
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
7
9
|
function stripPrefix(path) {
|
|
8
10
|
if (path === '/dev/null')
|
|
@@ -55,8 +57,11 @@ export function parsePatch(text) {
|
|
|
55
57
|
// Binary marker's own paths: for a bare-format deletion the marker line reads
|
|
56
58
|
// "Binary files a/X and /dev/null differ", so deriving identity from its second
|
|
57
59
|
// capture group would wrongly produce "/dev/null" instead of the real path X.
|
|
60
|
+
const added = binaryMatch[1] === NO_BASE_PATH;
|
|
58
61
|
if (!pendingGitPaths && currentFile !== null && currentFile.hunks.length === 0 && !currentFile.binary) {
|
|
59
62
|
currentFile.binary = true;
|
|
63
|
+
if (added)
|
|
64
|
+
currentFile.added = true;
|
|
60
65
|
}
|
|
61
66
|
else {
|
|
62
67
|
const path = pendingGitPaths?.new ?? stripPrefix(binaryMatch[2]);
|
|
@@ -65,13 +70,14 @@ export function parsePatch(text) {
|
|
|
65
70
|
oldPath: pendingGitPaths?.old ?? stripPrefix(binaryMatch[1]),
|
|
66
71
|
binary: true,
|
|
67
72
|
hunks: [],
|
|
73
|
+
...(added && { added }),
|
|
68
74
|
};
|
|
69
75
|
files.push(currentFile);
|
|
70
76
|
}
|
|
71
77
|
pendingGitPaths = null;
|
|
72
78
|
continue;
|
|
73
79
|
}
|
|
74
|
-
if (
|
|
80
|
+
if (EXTENDED_HEADER.test(line))
|
|
75
81
|
continue;
|
|
76
82
|
// A real file header only ever appears between hunks, never inside one — so once a
|
|
77
83
|
// hunk is open and still expects more lines, a "--- "/"+++ "-prefixed line is content
|