review-workspace 1.1.0 → 1.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/dist/bundle/round.js +71 -0
- package/dist/cli.js +19 -1
- package/dist/export/report.js +0 -30
- package/dist/questions/questions-log.js +26 -13
- package/dist/review-state/types.js +0 -1
- package/dist/server/create-server.js +1 -5
- package/package.json +1 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join, relative } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* A stable cross-round identity for one MR: repo slug + number, e.g.
|
|
5
|
+
* "founda/application/xds-viewer-ui!367".
|
|
6
|
+
*/
|
|
7
|
+
export function mrKeyFor(comparison) {
|
|
8
|
+
if (!comparison.repository || !comparison.number) {
|
|
9
|
+
throw new Error('comparison.repository and comparison.number are required to compute an mrKey');
|
|
10
|
+
}
|
|
11
|
+
return `${comparison.repository}!${comparison.number}`;
|
|
12
|
+
}
|
|
13
|
+
function readReviewDocument(bundlePath) {
|
|
14
|
+
return JSON.parse(readFileSync(join(bundlePath, 'review.json'), 'utf-8'));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Reads a bundle directory name shaped like `<name>-r{N}` and splits it into
|
|
18
|
+
* the chain's base name and that bundle's round number. A name with no
|
|
19
|
+
* `-r{N}` suffix is round 1.
|
|
20
|
+
*/
|
|
21
|
+
function parseBundleDirName(dirName) {
|
|
22
|
+
const match = dirName.match(/^(.+)-r(\d+)$/);
|
|
23
|
+
if (!match)
|
|
24
|
+
return { baseName: dirName, round: 1 };
|
|
25
|
+
return { baseName: match[1], round: Number(match[2]) };
|
|
26
|
+
}
|
|
27
|
+
/** The sibling bundle path a new round for `bundlePath` would scaffold into. */
|
|
28
|
+
export function nextRoundBundlePath(bundlePath) {
|
|
29
|
+
const { baseName, round } = parseBundleDirName(basename(bundlePath));
|
|
30
|
+
return join(dirname(bundlePath), `${baseName}-r${round + 1}`);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Detects, purely from what's on disk plus a supplied live head, whether a
|
|
34
|
+
* bundle's recorded Comparison has fallen behind its MR — the "new round"
|
|
35
|
+
* case alongside New/Existing/Improve bundle determination.
|
|
36
|
+
*/
|
|
37
|
+
export function checkRound(bundlePath, liveHead) {
|
|
38
|
+
const { comparison } = readReviewDocument(bundlePath);
|
|
39
|
+
return { needsNewRound: comparison.head !== liveHead, recordedHead: comparison.head, liveHead };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Scaffolds a new sibling round bundle for an MR whose live head has moved
|
|
43
|
+
* past what `bundlePath` recorded. Never mutates `bundlePath` (ADR 0002) —
|
|
44
|
+
* only creates the new `<name>-r{N}` directory alongside it, with its own
|
|
45
|
+
* Comparison, Unified Patch, and a `chain.json` linking it back.
|
|
46
|
+
*/
|
|
47
|
+
export function scaffoldNextRound(bundlePath, liveHead, options) {
|
|
48
|
+
const document = readReviewDocument(bundlePath);
|
|
49
|
+
if (document.comparison.head === liveHead) {
|
|
50
|
+
throw new Error(`Live head ${liveHead} matches the bundle's recorded head; no new round is needed`);
|
|
51
|
+
}
|
|
52
|
+
const { round: currentRound } = parseBundleDirName(basename(bundlePath));
|
|
53
|
+
const newBundlePath = nextRoundBundlePath(bundlePath);
|
|
54
|
+
if (existsSync(newBundlePath)) {
|
|
55
|
+
throw new Error(`Round bundle already exists at ${newBundlePath}`);
|
|
56
|
+
}
|
|
57
|
+
const chain = {
|
|
58
|
+
mrKey: mrKeyFor(document.comparison),
|
|
59
|
+
round: currentRound + 1,
|
|
60
|
+
previousBundle: relative(newBundlePath, bundlePath),
|
|
61
|
+
previousHead: document.comparison.head,
|
|
62
|
+
};
|
|
63
|
+
mkdirSync(newBundlePath, { recursive: true });
|
|
64
|
+
writeFileSync(join(newBundlePath, 'changes.diff'), options.patch);
|
|
65
|
+
writeFileSync(join(newBundlePath, 'review.json'), JSON.stringify({
|
|
66
|
+
schemaVersion: document.schemaVersion,
|
|
67
|
+
comparison: { ...document.comparison, head: liveHead },
|
|
68
|
+
}, null, 2));
|
|
69
|
+
writeFileSync(join(newBundlePath, 'chain.json'), JSON.stringify(chain, null, 2));
|
|
70
|
+
return { bundlePath: newBundlePath, chain };
|
|
71
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { validateBundle } from "./bundle/validate-bundle.js";
|
|
3
4
|
import { publishBundle } from "./bundle/publish.js";
|
|
5
|
+
import { checkRound, scaffoldNextRound } from "./bundle/round.js";
|
|
4
6
|
import { startReviewServer } from "./server/create-server.js";
|
|
5
7
|
const [, , command, bundlePath, ...rest] = process.argv;
|
|
6
8
|
function fail(message) {
|
|
@@ -12,7 +14,7 @@ function flagValue(name) {
|
|
|
12
14
|
return index === -1 ? undefined : rest[index + 1];
|
|
13
15
|
}
|
|
14
16
|
if (!command || !bundlePath) {
|
|
15
|
-
fail('Usage: review-workspace <open|publish|serve> <bundlePath> [--port N]');
|
|
17
|
+
fail('Usage: review-workspace <open|publish|serve|round> <bundlePath> [--port N] [--live-head SHA --patch FILE]');
|
|
16
18
|
}
|
|
17
19
|
if (command === 'open') {
|
|
18
20
|
const result = validateBundle(bundlePath);
|
|
@@ -31,6 +33,22 @@ else if (command === 'publish') {
|
|
|
31
33
|
}
|
|
32
34
|
console.log('Published.');
|
|
33
35
|
}
|
|
36
|
+
else if (command === 'round') {
|
|
37
|
+
const liveHead = flagValue('--live-head');
|
|
38
|
+
const patchPath = flagValue('--patch');
|
|
39
|
+
if (!liveHead || !patchPath) {
|
|
40
|
+
fail('Usage: review-workspace round <bundlePath> --live-head <sha> --patch <patchFile>');
|
|
41
|
+
}
|
|
42
|
+
const { needsNewRound } = checkRound(bundlePath, liveHead);
|
|
43
|
+
if (!needsNewRound) {
|
|
44
|
+
console.log(JSON.stringify({ needsNewRound: false }));
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const patch = readFileSync(patchPath, 'utf-8');
|
|
48
|
+
const result = scaffoldNextRound(bundlePath, liveHead, { patch });
|
|
49
|
+
console.log(JSON.stringify({ needsNewRound: true, ...result }));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
34
52
|
else if (command === 'serve') {
|
|
35
53
|
const port = Number(flagValue('--port') ?? 4317);
|
|
36
54
|
const handle = await startReviewServer(bundlePath, { port });
|
package/dist/export/report.js
CHANGED
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
function formatTarget(target) {
|
|
2
|
-
switch (target.type) {
|
|
3
|
-
case 'file':
|
|
4
|
-
return target.path;
|
|
5
|
-
case 'hunk':
|
|
6
|
-
return `${target.path}#hunk${target.hunkIndex}`;
|
|
7
|
-
case 'binary':
|
|
8
|
-
return `${target.path} (binary)`;
|
|
9
|
-
case 'line':
|
|
10
|
-
return `${target.path}:${target.line} (${target.side})`;
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
export function buildConcernComment(concern) {
|
|
14
|
-
const prefix = concern.target ? `[${formatTarget(concern.target)}] ` : '';
|
|
15
|
-
return `${prefix}${concern.note}`;
|
|
16
|
-
}
|
|
17
|
-
/** One comment per Concern, plain text — pasteable individually into an external tool. */
|
|
18
|
-
export function buildConcernComments(state) {
|
|
19
|
-
return state.concerns.map(buildConcernComment);
|
|
20
|
-
}
|
|
21
1
|
/**
|
|
22
2
|
* A self-contained Markdown report — no server needed to view it afterward.
|
|
23
3
|
* Read-only with respect to the bundle: never mutates state.json or questions.jsonl.
|
|
@@ -41,16 +21,6 @@ export function buildReport(document, state) {
|
|
|
41
21
|
}
|
|
42
22
|
}
|
|
43
23
|
lines.push('');
|
|
44
|
-
lines.push('## Concerns');
|
|
45
|
-
if (state.concerns.length === 0) {
|
|
46
|
-
lines.push('_None raised._');
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
49
|
-
for (const comment of buildConcernComments(state)) {
|
|
50
|
-
lines.push(`- ${comment}`);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
lines.push('');
|
|
54
24
|
lines.push('## Notes');
|
|
55
25
|
if (state.notes.length === 0) {
|
|
56
26
|
lines.push('_None._');
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
// File name predates the Comment/kind split (it only ever held Questions) — kept
|
|
5
|
+
// as-is so bundles created before this change keep reading correctly; only the
|
|
6
|
+
// in-memory shape gained `kind`, not the on-disk path.
|
|
4
7
|
function logPath(bundlePath) {
|
|
5
8
|
return join(bundlePath, 'questions.jsonl');
|
|
6
9
|
}
|
|
@@ -17,15 +20,22 @@ function readLog(bundlePath) {
|
|
|
17
20
|
function appendEntry(bundlePath, entry) {
|
|
18
21
|
appendFileSync(logPath(bundlePath), JSON.stringify(entry) + '\n');
|
|
19
22
|
}
|
|
20
|
-
/** Raises a new, immutable
|
|
23
|
+
/** Raises a new, immutable question Comment. Never mutates or removes any existing log entry. */
|
|
21
24
|
export function raiseQuestion(bundlePath, body, target) {
|
|
22
|
-
const entry = {
|
|
25
|
+
const entry = {
|
|
26
|
+
type: 'raised',
|
|
27
|
+
id: randomUUID(),
|
|
28
|
+
createdAt: new Date().toISOString(),
|
|
29
|
+
body,
|
|
30
|
+
target,
|
|
31
|
+
kind: 'question',
|
|
32
|
+
};
|
|
23
33
|
appendEntry(bundlePath, entry);
|
|
24
|
-
return { id: entry.id, createdAt: entry.createdAt, body: entry.body, target: entry.target, status: 'open' };
|
|
34
|
+
return { id: entry.id, createdAt: entry.createdAt, body: entry.body, target: entry.target, kind: 'question', status: 'open' };
|
|
25
35
|
}
|
|
26
36
|
/**
|
|
27
|
-
* Withdraws an existing
|
|
28
|
-
* correction is recorded as new log entries, never as an edit to the original.
|
|
37
|
+
* Withdraws an existing question Comment and raises its replacement in one call —
|
|
38
|
+
* a correction is recorded as new log entries, never as an edit to the original.
|
|
29
39
|
*/
|
|
30
40
|
export function withdrawAndReplaceQuestion(bundlePath, questionId, replacementBody, replacementTarget) {
|
|
31
41
|
const replacement = {
|
|
@@ -34,6 +44,7 @@ export function withdrawAndReplaceQuestion(bundlePath, questionId, replacementBo
|
|
|
34
44
|
createdAt: new Date().toISOString(),
|
|
35
45
|
body: replacementBody,
|
|
36
46
|
target: replacementTarget,
|
|
47
|
+
kind: 'question',
|
|
37
48
|
};
|
|
38
49
|
const withdrawal = {
|
|
39
50
|
type: 'withdrawn',
|
|
@@ -51,30 +62,32 @@ export function withdrawAndReplaceQuestion(bundlePath, questionId, replacementBo
|
|
|
51
62
|
createdAt: replacement.createdAt,
|
|
52
63
|
body: replacement.body,
|
|
53
64
|
target: replacement.target,
|
|
65
|
+
kind: 'question',
|
|
54
66
|
status: 'open',
|
|
55
67
|
},
|
|
56
68
|
};
|
|
57
69
|
}
|
|
58
|
-
/** Reduces the append-only log into the current set of
|
|
70
|
+
/** Reduces the append-only log into the current set of Comments. */
|
|
59
71
|
export function readQuestions(bundlePath) {
|
|
60
|
-
const
|
|
72
|
+
const comments = new Map();
|
|
61
73
|
for (const entry of readLog(bundlePath)) {
|
|
62
74
|
if (entry.type === 'raised') {
|
|
63
|
-
|
|
75
|
+
comments.set(entry.id, {
|
|
64
76
|
id: entry.id,
|
|
65
77
|
createdAt: entry.createdAt,
|
|
66
78
|
body: entry.body,
|
|
67
79
|
target: entry.target,
|
|
80
|
+
kind: entry.kind ?? 'question',
|
|
68
81
|
status: 'open',
|
|
69
82
|
});
|
|
70
83
|
}
|
|
71
84
|
else {
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
const comment = comments.get(entry.questionId);
|
|
86
|
+
if (comment) {
|
|
87
|
+
comment.status = 'withdrawn';
|
|
88
|
+
comment.supersededBy = entry.replacementId;
|
|
76
89
|
}
|
|
77
90
|
}
|
|
78
91
|
}
|
|
79
|
-
return [...
|
|
92
|
+
return [...comments.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
80
93
|
}
|
|
@@ -8,7 +8,7 @@ import { watchBundle } from "./watch-bundle.js";
|
|
|
8
8
|
import { raiseQuestion, readQuestions, withdrawAndReplaceQuestion } from "../questions/questions-log.js";
|
|
9
9
|
import { readReviewState, writeReviewState } from "../review-state/review-state.js";
|
|
10
10
|
import { render } from "../renderer/render.js";
|
|
11
|
-
import {
|
|
11
|
+
import { buildReport } from "../export/report.js";
|
|
12
12
|
import { resolveAssetPath } from "../security/asset-path.js";
|
|
13
13
|
export function generateWriteToken() {
|
|
14
14
|
return randomBytes(24).toString('hex');
|
|
@@ -118,10 +118,6 @@ export function startReviewServer(bundlePath, opts = {}) {
|
|
|
118
118
|
res.end(buildReport(latest.document, readReviewState(bundlePath)));
|
|
119
119
|
return;
|
|
120
120
|
}
|
|
121
|
-
if (req.method === 'GET' && url.pathname === '/concerns') {
|
|
122
|
-
respond(200, buildConcernComments(readReviewState(bundlePath)));
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
121
|
if (req.method === 'GET' && url.pathname.startsWith('/assets/')) {
|
|
126
122
|
const assetPath = decodeURIComponent(url.pathname.slice('/assets/'.length));
|
|
127
123
|
const resolution = resolveAssetPath(bundlePath, assetPath);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "review-workspace",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "A local, offline decision surface for reviewing a code Comparison — evidence-linked map of intent, behavior, risk, and observable effects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Salih Sayer",
|