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.
@@ -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 });
@@ -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 Question. Never mutates or removes any existing log entry. */
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 = { type: 'raised', id: randomUUID(), createdAt: new Date().toISOString(), body, target };
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 Question and raises its replacement in one call — a
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 Questions. */
70
+ /** Reduces the append-only log into the current set of Comments. */
59
71
  export function readQuestions(bundlePath) {
60
- const questions = new Map();
72
+ const comments = new Map();
61
73
  for (const entry of readLog(bundlePath)) {
62
74
  if (entry.type === 'raised') {
63
- questions.set(entry.id, {
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 question = questions.get(entry.questionId);
73
- if (question) {
74
- question.status = 'withdrawn';
75
- question.supersededBy = entry.replacementId;
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 [...questions.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
92
+ return [...comments.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
80
93
  }
@@ -1,6 +1,5 @@
1
1
  export const EMPTY_REVIEW_STATE = {
2
2
  groups: {},
3
- concerns: [],
4
3
  notes: [],
5
4
  decision: 'unset',
6
5
  };
@@ -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 { buildConcernComments, buildReport } from "../export/report.js";
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.1.0",
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",