review-workspace 1.2.0 → 1.4.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.
@@ -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 });
@@ -20,18 +20,26 @@ function readLog(bundlePath) {
20
20
  function appendEntry(bundlePath, entry) {
21
21
  appendFileSync(logPath(bundlePath), JSON.stringify(entry) + '\n');
22
22
  }
23
- /** Raises a new, immutable question Comment. Never mutates or removes any existing log entry. */
24
- export function raiseQuestion(bundlePath, body, target) {
23
+ /** Raises a new, immutable Comment. Never mutates or removes any existing log entry. */
24
+ export function raiseQuestion(bundlePath, body, target, kind = 'question') {
25
25
  const entry = {
26
26
  type: 'raised',
27
27
  id: randomUUID(),
28
28
  createdAt: new Date().toISOString(),
29
29
  body,
30
30
  target,
31
- kind: 'question',
31
+ kind,
32
32
  };
33
33
  appendEntry(bundlePath, entry);
34
- return { id: entry.id, createdAt: entry.createdAt, body: entry.body, target: entry.target, kind: 'question', status: 'open' };
34
+ return {
35
+ id: entry.id,
36
+ createdAt: entry.createdAt,
37
+ body: entry.body,
38
+ target: entry.target,
39
+ kind,
40
+ status: 'open',
41
+ resolved: false,
42
+ };
35
43
  }
36
44
  /**
37
45
  * Withdraws an existing question Comment and raises its replacement in one call —
@@ -64,9 +72,39 @@ export function withdrawAndReplaceQuestion(bundlePath, questionId, replacementBo
64
72
  target: replacement.target,
65
73
  kind: 'question',
66
74
  status: 'open',
75
+ resolved: false,
67
76
  },
68
77
  };
69
78
  }
79
+ function isOpenChangeRequest(comment) {
80
+ return comment.kind === 'change-request' && comment.status === 'open' && !comment.resolved;
81
+ }
82
+ /**
83
+ * Marks an open change-request Comment resolved. This is a human-only, reviewer-triggered
84
+ * action recorded in the same append-only log — nothing a Generator writes (the
85
+ * Review Document, `review.next.json`) can ever produce this entry.
86
+ *
87
+ * Validates before writing: a Comment that doesn't exist, or isn't currently an open
88
+ * change-request, is rejected without appending anything to the log.
89
+ */
90
+ export function resolveComment(bundlePath, commentId) {
91
+ const comment = readQuestions(bundlePath).find((c) => c.id === commentId);
92
+ if (!comment)
93
+ return { outcome: 'not-found' };
94
+ if (!isOpenChangeRequest(comment))
95
+ return { outcome: 'not-resolvable', comment };
96
+ const entry = {
97
+ type: 'resolved',
98
+ id: randomUUID(),
99
+ createdAt: new Date().toISOString(),
100
+ commentId,
101
+ };
102
+ appendEntry(bundlePath, entry);
103
+ return {
104
+ outcome: 'resolved',
105
+ comment: { ...comment, resolved: true, resolvedAt: entry.createdAt },
106
+ };
107
+ }
70
108
  /** Reduces the append-only log into the current set of Comments. */
71
109
  export function readQuestions(bundlePath) {
72
110
  const comments = new Map();
@@ -79,15 +117,23 @@ export function readQuestions(bundlePath) {
79
117
  target: entry.target,
80
118
  kind: entry.kind ?? 'question',
81
119
  status: 'open',
120
+ resolved: false,
82
121
  });
83
122
  }
84
- else {
123
+ else if (entry.type === 'withdrawn') {
85
124
  const comment = comments.get(entry.questionId);
86
125
  if (comment) {
87
126
  comment.status = 'withdrawn';
88
127
  comment.supersededBy = entry.replacementId;
89
128
  }
90
129
  }
130
+ else {
131
+ const comment = comments.get(entry.commentId);
132
+ if (comment && comment.kind === 'change-request') {
133
+ comment.resolved = true;
134
+ comment.resolvedAt = entry.createdAt;
135
+ }
136
+ }
91
137
  }
92
138
  return [...comments.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
93
139
  }
@@ -5,7 +5,7 @@ import { join } from 'node:path';
5
5
  import { validateBundle } from "../bundle/validate-bundle.js";
6
6
  import { publishBundle } from "../bundle/publish.js";
7
7
  import { watchBundle } from "./watch-bundle.js";
8
- import { raiseQuestion, readQuestions, withdrawAndReplaceQuestion } from "../questions/questions-log.js";
8
+ import { raiseQuestion, readQuestions, resolveComment, 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
11
  import { buildReport } from "../export/report.js";
@@ -15,6 +15,8 @@ export function generateWriteToken() {
15
15
  }
16
16
  const WRITE_TOKEN_HEADER = 'x-write-token';
17
17
  const WITHDRAW_PATH = /^\/questions\/([^/]+)\/withdraw$/;
18
+ const RESOLVE_PATH = /^\/questions\/([^/]+)\/resolve$/;
19
+ const COMMENT_KINDS = ['question', 'change-request'];
18
20
  const ASSET_CONTENT_TYPES = {
19
21
  '.png': 'image/png',
20
22
  '.jpg': 'image/jpeg',
@@ -144,12 +146,16 @@ export function startReviewServer(bundlePath, opts = {}) {
144
146
  if (req.method === 'POST' && url.pathname === '/questions') {
145
147
  readJsonBody(req)
146
148
  .then((body) => {
147
- const { body: text, target } = body;
149
+ const { body: text, target, kind } = body;
148
150
  if (typeof text !== 'string' || text.trim() === '') {
149
151
  respond(400, { error: 'body is required' });
150
152
  return;
151
153
  }
152
- respond(201, raiseQuestion(bundlePath, text, target));
154
+ if (kind !== undefined && !COMMENT_KINDS.includes(kind)) {
155
+ respond(400, { error: `kind must be one of ${COMMENT_KINDS.join(', ')}` });
156
+ return;
157
+ }
158
+ respond(201, raiseQuestion(bundlePath, text, target, kind ?? 'question'));
153
159
  })
154
160
  .catch(() => respond(400, { error: 'invalid JSON body' }));
155
161
  return;
@@ -168,6 +174,23 @@ export function startReviewServer(bundlePath, opts = {}) {
168
174
  .catch(() => respond(400, { error: 'invalid JSON body' }));
169
175
  return;
170
176
  }
177
+ // Human-only action: a Reviewer marking a change-request Comment resolved. This
178
+ // never reads from or writes to `review.next.json`/the Generator-owned Review
179
+ // Document — the Generator has no path to setting `resolved` (see ADR 0002).
180
+ const resolveMatch = RESOLVE_PATH.exec(url.pathname);
181
+ if (req.method === 'POST' && resolveMatch) {
182
+ const result = resolveComment(bundlePath, resolveMatch[1]);
183
+ if (result.outcome === 'not-found') {
184
+ respond(404, { error: 'no such Comment' });
185
+ return;
186
+ }
187
+ if (result.outcome === 'not-resolvable') {
188
+ respond(400, { error: 'Comment is not an open change-request' });
189
+ return;
190
+ }
191
+ respond(200, result.comment);
192
+ return;
193
+ }
171
194
  if (req.method === 'PUT' && url.pathname === '/state') {
172
195
  readJsonBody(req)
173
196
  .then((body) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "review-workspace",
3
- "version": "1.2.0",
3
+ "version": "1.4.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",