review-workspace 1.5.0 → 1.6.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,135 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { parsePatch } from "../patch/parse.js";
4
+ import { carryForwardComment, readQuestions } from "../questions/questions-log.js";
5
+ import { evaluateResolution } from "./resolution.js";
6
+ import { incrementalTouchedPaths } from "./incremental-patch.js";
7
+ export function createCaches() {
8
+ return { chain: new Map(), patch: new Map(), reviewDocument: new Map() };
9
+ }
10
+ export function readChain(bundlePath, caches = createCaches()) {
11
+ if (!caches.chain.has(bundlePath)) {
12
+ const path = join(bundlePath, 'chain.json');
13
+ caches.chain.set(bundlePath, existsSync(path) ? JSON.parse(readFileSync(path, 'utf-8')) : undefined);
14
+ }
15
+ return caches.chain.get(bundlePath);
16
+ }
17
+ export function resolvePreviousBundlePath(bundlePath, chain) {
18
+ return resolve(bundlePath, chain.previousBundle);
19
+ }
20
+ function readPatch(bundlePath, caches) {
21
+ if (!caches.patch.has(bundlePath)) {
22
+ caches.patch.set(bundlePath, parsePatch(readFileSync(join(bundlePath, 'changes.diff'), 'utf-8')));
23
+ }
24
+ return caches.patch.get(bundlePath);
25
+ }
26
+ function readReviewDocument(bundlePath, caches) {
27
+ if (!caches.reviewDocument.has(bundlePath)) {
28
+ caches.reviewDocument.set(bundlePath, JSON.parse(readFileSync(join(bundlePath, 'review.json'), 'utf-8')));
29
+ }
30
+ return caches.reviewDocument.get(bundlePath);
31
+ }
32
+ function openChangeRequests(bundlePath) {
33
+ return readQuestions(bundlePath).filter((c) => c.kind === 'change-request' && c.status === 'open' && !c.resolved);
34
+ }
35
+ /**
36
+ * Shared shape behind every chain-walking function below: a round-1 bundle (no
37
+ * `chain.json`) is the base case, otherwise resolve the previous round's bundle path
38
+ * and hand it (plus the chain itself, for its `round` number) to `step`.
39
+ */
40
+ function walkChain(bundlePath, caches, base, step) {
41
+ const chain = readChain(bundlePath, caches);
42
+ return chain ? step(resolvePreviousBundlePath(bundlePath, chain), chain) : base();
43
+ }
44
+ /**
45
+ * Copies every still-open change-request Comment from the previous round in the chain
46
+ * into this round's own comment log, so it's visible and actionable here too. Idempotent
47
+ * — safe to call every time a round-N bundle is opened, validated, or rendered.
48
+ * No-ops for a round-1 bundle (no `chain.json`).
49
+ */
50
+ export function carryForwardChain(bundlePath, caches = createCaches()) {
51
+ walkChain(bundlePath, caches, () => undefined, (previousBundlePath) => {
52
+ for (const comment of openChangeRequests(previousBundlePath)) {
53
+ carryForwardComment(bundlePath, comment);
54
+ }
55
+ });
56
+ }
57
+ /**
58
+ * Evaluates every comment carried forward from the previous round against this round's
59
+ * incremental patch. Assumes `carryForwardChain` has already copied them into this
60
+ * round's own log — this only reads, never writes.
61
+ */
62
+ export function computeCarriedResolutions(bundlePath, caches = createCaches()) {
63
+ return walkChain(bundlePath, caches, () => [], (previousBundlePath) => {
64
+ const carriedIds = new Set(openChangeRequests(previousBundlePath).map((c) => c.id));
65
+ if (carriedIds.size === 0)
66
+ return [];
67
+ const previousPatch = readPatch(previousBundlePath, caches);
68
+ const currentPatch = readPatch(bundlePath, caches);
69
+ return readQuestions(bundlePath)
70
+ .filter((c) => carriedIds.has(c.id))
71
+ .map((comment) => ({ comment, resolution: evaluateResolution(comment, previousPatch, currentPatch) }));
72
+ });
73
+ }
74
+ function findOriginRound(bundlePath, commentId, caches) {
75
+ return walkChain(bundlePath, caches, () => 1, (previousBundlePath, chain) => {
76
+ const inPreviousRound = readQuestions(previousBundlePath).some((c) => c.id === commentId);
77
+ return inPreviousRound ? findOriginRound(previousBundlePath, commentId, caches) : chain.round;
78
+ });
79
+ }
80
+ /**
81
+ * The full comment history for this bundle: its own log, plus every ancestor round's
82
+ * comments not already present here (own log id, e.g. a resolved-in-round-1 change
83
+ * request never carried forward as open). Resolved comments are never dropped — they
84
+ * simply keep showing from whichever round's log actually holds them (framework.md's
85
+ * "never silently discard evidence").
86
+ */
87
+ export function collectCommentHistory(bundlePath, caches = createCaches()) {
88
+ const own = readQuestions(bundlePath).map((c) => ({ ...c, originRound: findOriginRound(bundlePath, c.id, caches) }));
89
+ return walkChain(bundlePath, caches, () => own, (previousBundlePath) => {
90
+ const ownIds = new Set(own.map((c) => c.id));
91
+ const ancestorHistory = collectCommentHistory(previousBundlePath, caches).filter((c) => !ownIds.has(c.id));
92
+ return [...own, ...ancestorHistory].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
93
+ });
94
+ }
95
+ /** Every Comment relevant to this bundle, with its claimed Resolution attached where one applies. */
96
+ export function listComments(bundlePath, caches = createCaches()) {
97
+ const resolutions = new Map(computeCarriedResolutions(bundlePath, caches).map((c) => [c.comment.id, c.resolution]));
98
+ return collectCommentHistory(bundlePath, caches).map((comment) => ({ ...comment, resolution: resolutions.get(comment.id) }));
99
+ }
100
+ function targetKey(target) {
101
+ switch (target.type) {
102
+ case 'file':
103
+ return `file:${target.path}`;
104
+ case 'binary':
105
+ return `binary:${target.path}`;
106
+ case 'hunk':
107
+ return `hunk:${target.path}:${target.hunkIndex}`;
108
+ case 'line':
109
+ return `line:${target.path}:${target.side}:${target.line}`;
110
+ }
111
+ }
112
+ /**
113
+ * Behavioral Groups/Annotations carried forward from earlier rounds for material the
114
+ * incremental patch doesn't touch — the round-N Generator is only expected to write
115
+ * fresh ones for genuinely new material (framework.md's "slim rounds"). Recurses the
116
+ * whole chain so a group/annotation keeps surfacing across every later round until a
117
+ * round's incremental patch actually touches its file, or a later round redeclares it.
118
+ */
119
+ export function collectCarriedReviewContent(bundlePath, ownDocument, caches = createCaches()) {
120
+ return walkChain(bundlePath, caches, () => ({ behavioralGroups: [], annotations: [] }), (previousBundlePath) => {
121
+ const previousDocument = readReviewDocument(previousBundlePath, caches);
122
+ const previousCarried = collectCarriedReviewContent(previousBundlePath, previousDocument, caches);
123
+ const previousAll = {
124
+ behavioralGroups: [...(previousDocument.behavioralGroups ?? []), ...previousCarried.behavioralGroups],
125
+ annotations: [...(previousDocument.annotations ?? []), ...previousCarried.annotations],
126
+ };
127
+ const touchedPaths = incrementalTouchedPaths(readPatch(previousBundlePath, caches), readPatch(bundlePath, caches));
128
+ const ownTargetKeys = new Set((ownDocument.annotations ?? []).map((a) => targetKey(a.target)));
129
+ const ownGroupIds = new Set((ownDocument.behavioralGroups ?? []).map((g) => g.id));
130
+ return {
131
+ annotations: previousAll.annotations.filter((a) => !touchedPaths.has(a.target.path) && !ownTargetKeys.has(targetKey(a.target))),
132
+ behavioralGroups: previousAll.behavioralGroups.filter((g) => !ownGroupIds.has(g.id) && !(g.targets ?? []).some((t) => touchedPaths.has(t.path))),
133
+ };
134
+ });
135
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Both a round's own `changes.diff` and its predecessor's are full base..head patches
3
+ * (ADR 0002 — every bundle is a real Comparison), so there is no git access to compute
4
+ * `previousHead..head` directly (see framework.md's product boundary). Instead, a hunk
5
+ * that appears verbatim in both is treated as unchanged since the previous round; one
6
+ * that doesn't is incremental. This slightly over-reports "touched" when unrelated
7
+ * earlier edits shift a hunk's line numbers without changing its content, which is the
8
+ * conservative direction (never silently treats real change as untouched).
9
+ */
10
+ function hunkFingerprint(path, hunkIndex, hunk) {
11
+ return JSON.stringify([path, hunkIndex, hunk]);
12
+ }
13
+ /** Hunks in `currentPatch`'s file at `path` that weren't present, verbatim and at the same
14
+ * index, in `previousPatch`. A file absent from `previousPatch` counts as entirely incremental.
15
+ * The index is part of the fingerprint so two textually-identical hunks at different
16
+ * locations (e.g. near-duplicate boilerplate) can never be mistaken for each other. */
17
+ export function incrementalHunks(previousPatch, currentPatch, path) {
18
+ const currentFile = currentPatch.files.find((f) => f.path === path);
19
+ if (!currentFile)
20
+ return [];
21
+ const previousFile = previousPatch.files.find((f) => f.path === path);
22
+ if (!previousFile)
23
+ return currentFile.hunks;
24
+ const previousHunkKeys = new Set(previousFile.hunks.map((hunk, index) => hunkFingerprint(path, index, hunk)));
25
+ return currentFile.hunks.filter((hunk, index) => !previousHunkKeys.has(hunkFingerprint(path, index, hunk)));
26
+ }
27
+ /** File paths in `currentPatch` with at least one hunk incremental since `previousPatch`. */
28
+ export function incrementalTouchedPaths(previousPatch, currentPatch) {
29
+ const touched = new Set();
30
+ for (const file of currentPatch.files) {
31
+ if (incrementalHunks(previousPatch, currentPatch, file.path).length > 0)
32
+ touched.add(file.path);
33
+ }
34
+ return touched;
35
+ }
@@ -0,0 +1,68 @@
1
+ import { resolveTarget } from "./resolve-diagnostics.js";
2
+ import { incrementalHunks } from "./incremental-patch.js";
3
+ function targetHunkIndex(target, patch) {
4
+ const file = patch.files.find((f) => f.path === target.path);
5
+ if (!file)
6
+ return undefined;
7
+ if (target.type === 'hunk')
8
+ return target.hunkIndex;
9
+ if (target.type !== 'line')
10
+ return undefined;
11
+ return file.hunks.findIndex((h) => h.lines.some((l) => (target.side === 'base' ? l.oldLine === target.line : l.newLine === target.line)));
12
+ }
13
+ /**
14
+ * Evaluates one carried-forward change-request Comment against the round's incremental
15
+ * patch. `target-gone` reuses the same Target-resolution logic the engine already uses
16
+ * to flag stale Targets in `resolve-diagnostics.ts`, rather than reinventing it.
17
+ */
18
+ export function evaluateResolution(comment, previousPatch, currentPatch) {
19
+ const target = comment.target;
20
+ if (!target) {
21
+ const anyIncrementalChange = currentPatch.files.some((f) => incrementalHunks(previousPatch, currentPatch, f.path).length > 0);
22
+ return anyIncrementalChange
23
+ ? {
24
+ commentId: comment.id,
25
+ status: 'target-partially-touched',
26
+ evidence: 'The incremental patch changed other content, but this comment has no Target to correlate against.',
27
+ }
28
+ : { commentId: comment.id, status: 'target-untouched', evidence: 'No incremental changes were made in this round.' };
29
+ }
30
+ const staleDiagnostic = resolveTarget(target, currentPatch);
31
+ if (staleDiagnostic) {
32
+ const detail = 'detail' in staleDiagnostic ? staleDiagnostic.detail : 'the Target no longer resolves';
33
+ return {
34
+ commentId: comment.id,
35
+ status: 'target-gone',
36
+ evidence: `The comment's original Target no longer resolves against this round's patch: ${detail}`,
37
+ };
38
+ }
39
+ const touchedHunks = incrementalHunks(previousPatch, currentPatch, target.path);
40
+ if (touchedHunks.length === 0) {
41
+ return {
42
+ commentId: comment.id,
43
+ status: 'target-untouched',
44
+ evidence: `${target.path} has no incremental changes since the previous round.`,
45
+ };
46
+ }
47
+ if (target.type === 'file' || target.type === 'binary') {
48
+ return {
49
+ commentId: comment.id,
50
+ status: 'target-partially-touched',
51
+ evidence: `${touchedHunks.length} hunk(s) changed in ${target.path} since the previous round.`,
52
+ };
53
+ }
54
+ const currentFile = currentPatch.files.find((f) => f.path === target.path);
55
+ const hunkIndex = targetHunkIndex(target, currentPatch);
56
+ const targetedHunkChanged = hunkIndex !== undefined && hunkIndex >= 0 && touchedHunks.includes(currentFile.hunks[hunkIndex]);
57
+ return targetedHunkChanged
58
+ ? {
59
+ commentId: comment.id,
60
+ status: 'target-touched',
61
+ evidence: `The hunk containing this comment's Target changed in the incremental patch — this is a mechanical signal only, not a judgment that the underlying concern was actually addressed.`,
62
+ }
63
+ : {
64
+ commentId: comment.id,
65
+ status: 'target-partially-touched',
66
+ evidence: `${target.path} changed elsewhere in the incremental patch, but not at this comment's exact Target.`,
67
+ };
68
+ }
@@ -22,7 +22,8 @@ function checkAssetPath(bundlePath, assetPath, limits) {
22
22
  }
23
23
  return undefined;
24
24
  }
25
- function resolveTarget(target, patch) {
25
+ /** Resolves a Target against a patch, returning the Diagnostic that explains why it's unresolved/stale, or undefined when it resolves cleanly. */
26
+ export function resolveTarget(target, patch) {
26
27
  const file = findFile(patch, target.path);
27
28
  if (target.type === 'file') {
28
29
  if (!file)
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import { validateBundle } from "./bundle/validate-bundle.js";
4
4
  import { publishBundleAndExportHandoff } from "./bundle/publish-with-handoff.js";
5
5
  import { checkRound, scaffoldNextRound } from "./bundle/round.js";
6
6
  import { askForRepoPathOnStdin } from "./handoff/ask-repo-path.js";
7
+ import { carryForwardChain } from "./bundle/carry-forward.js";
7
8
  import { startReviewServer } from "./server/create-server.js";
8
9
  const [, , command, bundlePath, ...rest] = process.argv;
9
10
  function fail(message) {
@@ -18,6 +19,7 @@ if (!command || !bundlePath) {
18
19
  fail('Usage: review-workspace <open|publish|serve|round> <bundlePath> [--port N] [--live-head SHA --patch FILE]');
19
20
  }
20
21
  if (command === 'open') {
22
+ carryForwardChain(bundlePath);
21
23
  const result = validateBundle(bundlePath);
22
24
  if (!result.valid) {
23
25
  fail(`Cannot open bundle: ${result.blockingReason} — ${result.message}`);
@@ -105,6 +105,25 @@ export function resolveComment(bundlePath, commentId) {
105
105
  comment: { ...comment, resolved: true, resolvedAt: entry.createdAt },
106
106
  };
107
107
  }
108
+ /**
109
+ * Copies a still-open change-request Comment from an earlier round's log into this
110
+ * round's own log, preserving its id and original createdAt so a Reviewer can act on it
111
+ * (resolve it) directly from this round. No-ops if this round's log already has a
112
+ * `raised` entry for that id — safe to call on every bundle open/validate/render.
113
+ */
114
+ export function carryForwardComment(bundlePath, comment) {
115
+ const alreadyCarried = readLog(bundlePath).some((entry) => entry.type === 'raised' && entry.id === comment.id);
116
+ if (alreadyCarried)
117
+ return;
118
+ appendEntry(bundlePath, {
119
+ type: 'raised',
120
+ id: comment.id,
121
+ createdAt: comment.createdAt,
122
+ body: comment.body,
123
+ target: comment.target,
124
+ kind: comment.kind,
125
+ });
126
+ }
108
127
  /** Reduces the append-only log into the current set of Comments. */
109
128
  export function readQuestions(bundlePath) {
110
129
  const comments = new Map();
@@ -1,4 +1,5 @@
1
1
  import { resolve } from 'node:path';
2
+ import { collectCarriedReviewContent, createCaches, readChain } from "../bundle/carry-forward.js";
2
3
  import { ALLOWED_ASSET_EXTENSIONS } from "../security/asset-path.js";
3
4
  /**
4
5
  * The app never launches a Generator itself (per framework.md) — this only
@@ -123,7 +124,22 @@ export function render(document, patch, diagnostics, bundlePath) {
123
124
  list.push(d);
124
125
  diagnosticsByPath.set(path, list);
125
126
  }
126
- const groups = [...(document.behavioralGroups ?? [])]
127
+ // Both `chain` below and `collectCarriedReviewContent` read the same chain.json —
128
+ // sharing one `Caches` instance across both calls (scoped to this single `render()`
129
+ // call, then discarded) makes that one disk read either way, without ever letting the
130
+ // read survive past this call to go stale on a later one.
131
+ const caches = createCaches();
132
+ const chain = readChain(bundlePath, caches);
133
+ // Behavioral Groups/Annotations from earlier rounds the incremental patch doesn't
134
+ // touch surface here unchanged (framework.md's "slim rounds") — round N's own
135
+ // document only needs fresh ones for genuinely new material.
136
+ const carried = collectCarriedReviewContent(bundlePath, document, caches);
137
+ const effectiveDocument = {
138
+ ...document,
139
+ behavioralGroups: [...(document.behavioralGroups ?? []), ...carried.behavioralGroups],
140
+ annotations: [...(document.annotations ?? []), ...carried.annotations],
141
+ };
142
+ const groups = [...(effectiveDocument.behavioralGroups ?? [])]
127
143
  .sort((a, b) => a.order - b.order)
128
144
  .map((g) => ({
129
145
  id: g.id,
@@ -153,7 +169,7 @@ export function render(document, patch, diagnostics, bundlePath) {
153
169
  const files = orderedPaths
154
170
  .map((path) => patchFileByPath.get(path))
155
171
  .filter((f) => f !== undefined)
156
- .map((f) => renderFile(f, document, diagnosticsByPath.get(f.path) ?? []));
172
+ .map((f) => renderFile(f, effectiveDocument, diagnosticsByPath.get(f.path) ?? []));
157
173
  const attachedPaths = new Set(patch.files.map((f) => f.path));
158
174
  const bundleLevelDiagnostics = diagnostics.filter((d) => {
159
175
  const path = diagnosticPath(d);
@@ -161,12 +177,13 @@ export function render(document, patch, diagnostics, bundlePath) {
161
177
  });
162
178
  return {
163
179
  comparison: document.comparison,
180
+ round: chain?.round ?? 1,
164
181
  groups,
165
182
  files,
166
183
  diagnostics: bundleLevelDiagnostics,
167
184
  generatorPrompt: buildGeneratorPrompt(bundlePath),
168
185
  answers: document.answers ?? [],
169
- summary: renderSummary(document, attachedPaths),
186
+ summary: renderSummary(effectiveDocument, attachedPaths),
170
187
  };
171
188
  }
172
189
  /** Dangling ids/paths are dropped rather than diagnosed — same treatment as evidence/verification targetIds. */
@@ -5,8 +5,9 @@ import { join } from 'node:path';
5
5
  import { validateBundle } from "../bundle/validate-bundle.js";
6
6
  import { publishBundleAndExportHandoff } from "../bundle/publish-with-handoff.js";
7
7
  import { watchBundle } from "./watch-bundle.js";
8
- import { raiseQuestion, readQuestions, resolveComment, withdrawAndReplaceQuestion } from "../questions/questions-log.js";
8
+ import { raiseQuestion, resolveComment, withdrawAndReplaceQuestion } from "../questions/questions-log.js";
9
9
  import { askForRepoPathOnStdin } from "../handoff/ask-repo-path.js";
10
+ import { carryForwardChain, listComments } from "../bundle/carry-forward.js";
10
11
  import { readReviewState, writeReviewState } from "../review-state/review-state.js";
11
12
  import { render } from "../renderer/render.js";
12
13
  import { buildReport } from "../export/report.js";
@@ -73,6 +74,10 @@ function readJsonBody(req) {
73
74
  export function startReviewServer(bundlePath, opts = {}) {
74
75
  const writeToken = opts.writeToken ?? generateWriteToken();
75
76
  const askForRepoPath = opts.askForRepoPath ?? askForRepoPathOnStdin;
77
+ // A round-N bundle (has chain.json) carries forward every still-open change-request
78
+ // from the previous round into its own log the moment it's opened — idempotent, so
79
+ // safe to repeat on every subsequent change below too.
80
+ carryForwardChain(bundlePath);
76
81
  // The served document is cached and only refreshed by the watcher below, so a
77
82
  // publish must actually trigger re-validation for /document to ever change —
78
83
  // this is what makes file watching load-bearing rather than a no-op wrapper
@@ -80,6 +85,7 @@ export function startReviewServer(bundlePath, opts = {}) {
80
85
  let latest = validateBundle(bundlePath);
81
86
  const watcher = watchBundle(bundlePath);
82
87
  watcher.emitter.on('change', () => {
88
+ carryForwardChain(bundlePath);
83
89
  latest = validateBundle(bundlePath);
84
90
  });
85
91
  const server = createServer((req, res) => {
@@ -106,7 +112,7 @@ export function startReviewServer(bundlePath, opts = {}) {
106
112
  return;
107
113
  }
108
114
  if (req.method === 'GET' && url.pathname === '/questions') {
109
- respond(200, readQuestions(bundlePath));
115
+ respond(200, listComments(bundlePath));
110
116
  return;
111
117
  }
112
118
  if (req.method === 'GET' && url.pathname === '/state') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "review-workspace",
3
- "version": "1.5.0",
3
+ "version": "1.6.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",