review-workspace 1.4.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.
- package/dist/bundle/carry-forward.js +135 -0
- package/dist/bundle/incremental-patch.js +35 -0
- package/dist/bundle/publish-with-handoff.js +31 -0
- package/dist/bundle/resolution.js +68 -0
- package/dist/bundle/resolve-diagnostics.js +2 -1
- package/dist/bundle/round.js +7 -0
- package/dist/cli.js +8 -2
- package/dist/handoff/ask-repo-path.js +17 -0
- package/dist/handoff/build-handoff-markdown.js +110 -0
- package/dist/handoff/ensure-gitignore.js +24 -0
- package/dist/handoff/export-handoff.js +28 -0
- package/dist/handoff/repo-path-cache.js +28 -0
- package/dist/questions/questions-log.js +19 -0
- package/dist/renderer/render.js +20 -3
- package/dist/server/create-server.js +14 -5
- package/package.json +1 -1
- package/ui-dist/_app/{index-CIfDjuhH.js → index-CXxgqbw-.js} +22 -22
- package/ui-dist/index.html +1 -1
|
@@ -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,31 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { exportHandoff } from "../handoff/export-handoff.js";
|
|
4
|
+
import { readQuestions } from "../questions/questions-log.js";
|
|
5
|
+
import { DEFAULT_LIMITS } from "../security/limits.js";
|
|
6
|
+
import { publishBundle } from "./publish.js";
|
|
7
|
+
import { readChain } from "./round.js";
|
|
8
|
+
/**
|
|
9
|
+
* Publishes a bundle and, only once the publish itself succeeds, exports its
|
|
10
|
+
* open change-request Comments as a hand-off file into the target repo's
|
|
11
|
+
* working tree. The round number comes from `chain.json` when present
|
|
12
|
+
* (round N>1), otherwise round 1 — independent of chaining, same as the
|
|
13
|
+
* hand-off export itself.
|
|
14
|
+
*/
|
|
15
|
+
export async function publishBundleAndExportHandoff(bundlePath, options) {
|
|
16
|
+
const result = publishBundle(bundlePath, options.limits ?? DEFAULT_LIMITS);
|
|
17
|
+
if (!result.ok)
|
|
18
|
+
return result;
|
|
19
|
+
const document = JSON.parse(readFileSync(join(bundlePath, 'review.json'), 'utf-8'));
|
|
20
|
+
const round = readChain(bundlePath)?.round ?? 1;
|
|
21
|
+
const handoff = await exportHandoff({
|
|
22
|
+
bundlePath,
|
|
23
|
+
comparison: document.comparison,
|
|
24
|
+
round,
|
|
25
|
+
comments: readQuestions(bundlePath),
|
|
26
|
+
annotations: document.annotations ?? [],
|
|
27
|
+
ask: options.ask,
|
|
28
|
+
repoPathCachePath: options.repoPathCachePath,
|
|
29
|
+
});
|
|
30
|
+
return { ...result, handoff };
|
|
31
|
+
}
|
|
@@ -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
|
-
|
|
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/bundle/round.js
CHANGED
|
@@ -13,6 +13,13 @@ export function mrKeyFor(comparison) {
|
|
|
13
13
|
function readReviewDocument(bundlePath) {
|
|
14
14
|
return JSON.parse(readFileSync(join(bundlePath, 'review.json'), 'utf-8'));
|
|
15
15
|
}
|
|
16
|
+
/** Reads a bundle's `chain.json`, if it has one — absent for every round-1 bundle. */
|
|
17
|
+
export function readChain(bundlePath) {
|
|
18
|
+
const path = join(bundlePath, 'chain.json');
|
|
19
|
+
if (!existsSync(path))
|
|
20
|
+
return undefined;
|
|
21
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
22
|
+
}
|
|
16
23
|
/**
|
|
17
24
|
* Reads a bundle directory name shaped like `<name>-r{N}` and splits it into
|
|
18
25
|
* the chain's base name and that bundle's round number. A name with no
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { validateBundle } from "./bundle/validate-bundle.js";
|
|
4
|
-
import {
|
|
4
|
+
import { publishBundleAndExportHandoff } from "./bundle/publish-with-handoff.js";
|
|
5
5
|
import { checkRound, scaffoldNextRound } from "./bundle/round.js";
|
|
6
|
+
import { askForRepoPathOnStdin } from "./handoff/ask-repo-path.js";
|
|
7
|
+
import { carryForwardChain } from "./bundle/carry-forward.js";
|
|
6
8
|
import { startReviewServer } from "./server/create-server.js";
|
|
7
9
|
const [, , command, bundlePath, ...rest] = process.argv;
|
|
8
10
|
function fail(message) {
|
|
@@ -17,6 +19,7 @@ if (!command || !bundlePath) {
|
|
|
17
19
|
fail('Usage: review-workspace <open|publish|serve|round> <bundlePath> [--port N] [--live-head SHA --patch FILE]');
|
|
18
20
|
}
|
|
19
21
|
if (command === 'open') {
|
|
22
|
+
carryForwardChain(bundlePath);
|
|
20
23
|
const result = validateBundle(bundlePath);
|
|
21
24
|
if (!result.valid) {
|
|
22
25
|
fail(`Cannot open bundle: ${result.blockingReason} — ${result.message}`);
|
|
@@ -27,11 +30,14 @@ if (command === 'open') {
|
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
32
|
else if (command === 'publish') {
|
|
30
|
-
const result =
|
|
33
|
+
const result = await publishBundleAndExportHandoff(bundlePath, { ask: askForRepoPathOnStdin });
|
|
31
34
|
if (!result.ok) {
|
|
32
35
|
fail(`Publish rejected: ${result.blockingReason} — ${result.message}`);
|
|
33
36
|
}
|
|
34
37
|
console.log('Published.');
|
|
38
|
+
if (result.handoff?.written) {
|
|
39
|
+
console.log(`Hand-off file written: ${result.handoff.filePath}`);
|
|
40
|
+
}
|
|
35
41
|
}
|
|
36
42
|
else if (command === 'round') {
|
|
37
43
|
const liveHead = flagValue('--live-head');
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { stdin, stdout } from 'node:process';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
/**
|
|
4
|
+
* Real interactive `AskForRepoPath` implementation for CLI/server use —
|
|
5
|
+
* prompts on stdin/stdout. Kept separate from `repo-path-cache.ts` so that
|
|
6
|
+
* module stays free of any actual I/O and easy to unit test with a stub `ask`.
|
|
7
|
+
*/
|
|
8
|
+
export async function askForRepoPathOnStdin(repoSlug) {
|
|
9
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
10
|
+
try {
|
|
11
|
+
const answer = await rl.question(`Local checkout path for ${repoSlug}: `);
|
|
12
|
+
return answer.trim();
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
rl.close();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
const GENERAL_GROUP = '(general — no Target)';
|
|
3
|
+
function targetPath(target) {
|
|
4
|
+
return target.path;
|
|
5
|
+
}
|
|
6
|
+
function targetKey(target) {
|
|
7
|
+
switch (target.type) {
|
|
8
|
+
case 'file':
|
|
9
|
+
return `file:${target.path}`;
|
|
10
|
+
case 'binary':
|
|
11
|
+
return `binary:${target.path}`;
|
|
12
|
+
case 'hunk':
|
|
13
|
+
return `hunk:${target.path}:${target.hunkIndex}`;
|
|
14
|
+
case 'line':
|
|
15
|
+
return `line:${target.path}:${target.side}:${target.line}`;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// Same-type-same-Target only: a file-level comment does not pick up
|
|
19
|
+
// hunk-level Annotations on that file (or vice versa). Cross-granularity
|
|
20
|
+
// matching is a coarser question ("does this Annotation touch material this
|
|
21
|
+
// comment also concerns?") that issue #10 doesn't ask for — it wants the
|
|
22
|
+
// Annotation the comment was raised against, i.e. the same Target.
|
|
23
|
+
function targetsEqual(a, b) {
|
|
24
|
+
return targetKey(a) === targetKey(b);
|
|
25
|
+
}
|
|
26
|
+
function describeTarget(target) {
|
|
27
|
+
switch (target.type) {
|
|
28
|
+
case 'file':
|
|
29
|
+
return `File: ${target.path}`;
|
|
30
|
+
case 'hunk':
|
|
31
|
+
return `Hunk #${target.hunkIndex} in ${target.path}`;
|
|
32
|
+
case 'line':
|
|
33
|
+
return `Line ${target.line} (${target.side}) in ${target.path}`;
|
|
34
|
+
case 'binary':
|
|
35
|
+
return `Binary change: ${target.path}`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function openChangeRequests(comments) {
|
|
39
|
+
return comments.filter((c) => c.kind === 'change-request' && c.status === 'open' && !c.resolved);
|
|
40
|
+
}
|
|
41
|
+
function groupByFile(comments) {
|
|
42
|
+
const byFile = new Map();
|
|
43
|
+
for (const comment of comments) {
|
|
44
|
+
const key = comment.target ? targetPath(comment.target) : GENERAL_GROUP;
|
|
45
|
+
const group = byFile.get(key) ?? [];
|
|
46
|
+
group.push(comment);
|
|
47
|
+
byFile.set(key, group);
|
|
48
|
+
}
|
|
49
|
+
return byFile;
|
|
50
|
+
}
|
|
51
|
+
function orderedFileKeys(byFile) {
|
|
52
|
+
return [...byFile.keys()].sort((a, b) => {
|
|
53
|
+
if (a === GENERAL_GROUP)
|
|
54
|
+
return 1;
|
|
55
|
+
if (b === GENERAL_GROUP)
|
|
56
|
+
return -1;
|
|
57
|
+
return a.localeCompare(b);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function metadataBlock(input) {
|
|
61
|
+
return [
|
|
62
|
+
'<!--',
|
|
63
|
+
'review-workspace-handoff',
|
|
64
|
+
`bundlePath: ${resolve(input.bundlePath)}`,
|
|
65
|
+
`repository: ${input.comparison.repository ?? ''}`,
|
|
66
|
+
`mrNumber: ${input.comparison.number ?? ''}`,
|
|
67
|
+
`round: ${input.round}`,
|
|
68
|
+
'-->',
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Builds the hand-off Markdown a separate implementer-side skill parses.
|
|
73
|
+
* Content is raw: every open change-request Comment verbatim, grouped by
|
|
74
|
+
* file, with its exact Target and any Annotation raised against that same
|
|
75
|
+
* Target for context. This exact shape is a parsed contract — golden-tested,
|
|
76
|
+
* do not reformat without updating that consumer too.
|
|
77
|
+
*/
|
|
78
|
+
export function buildHandoffMarkdown(input) {
|
|
79
|
+
const comments = openChangeRequests(input.comments);
|
|
80
|
+
const lines = [`# Change requests — round ${input.round}`, '', ...metadataBlock(input), ''];
|
|
81
|
+
if (comments.length === 0) {
|
|
82
|
+
lines.push('_No open change-request comments._');
|
|
83
|
+
return lines.join('\n') + '\n';
|
|
84
|
+
}
|
|
85
|
+
const byFile = groupByFile(comments);
|
|
86
|
+
for (const file of orderedFileKeys(byFile)) {
|
|
87
|
+
lines.push(`## ${file}`);
|
|
88
|
+
lines.push('');
|
|
89
|
+
const fileComments = [...(byFile.get(file) ?? [])].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
90
|
+
for (const comment of fileComments) {
|
|
91
|
+
lines.push(comment.target ? `### ${describeTarget(comment.target)}` : '### (no Target)');
|
|
92
|
+
if (comment.target?.type === 'line') {
|
|
93
|
+
lines.push(`> ${comment.target.expectedText}`);
|
|
94
|
+
}
|
|
95
|
+
lines.push('');
|
|
96
|
+
lines.push(comment.body);
|
|
97
|
+
lines.push('');
|
|
98
|
+
const relatedAnnotations = comment.target
|
|
99
|
+
? input.annotations.filter((a) => targetsEqual(a.target, comment.target))
|
|
100
|
+
: [];
|
|
101
|
+
for (const annotation of relatedAnnotations) {
|
|
102
|
+
lines.push(`**Annotation (${annotation.kind ?? 'note'}):** ${annotation.summary}`);
|
|
103
|
+
lines.push('');
|
|
104
|
+
}
|
|
105
|
+
lines.push('---');
|
|
106
|
+
lines.push('');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return lines.join('\n').replace(/\n{2,}$/, '\n');
|
|
110
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const ENTRY = '.review-feedback/';
|
|
4
|
+
const COVERING_PATTERNS = new Set(['.review-feedback/', '.review-feedback', '.review-feedback/**', '.review-feedback/*']);
|
|
5
|
+
function lineCoversHandoffDir(line) {
|
|
6
|
+
return COVERING_PATTERNS.has(line.trim());
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Ensures the target repo's `.gitignore` excludes `.review-feedback/` —
|
|
10
|
+
* creating the file if it doesn't exist, appending the entry if the file
|
|
11
|
+
* exists but neither an exact nor a broader matching entry already covers it.
|
|
12
|
+
*/
|
|
13
|
+
export function ensureGitignoreExcludesHandoff(repoPath) {
|
|
14
|
+
const gitignorePath = join(repoPath, '.gitignore');
|
|
15
|
+
if (!existsSync(gitignorePath)) {
|
|
16
|
+
writeFileSync(gitignorePath, `${ENTRY}\n`);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const content = readFileSync(gitignorePath, 'utf-8');
|
|
20
|
+
if (content.split('\n').some(lineCoversHandoffDir))
|
|
21
|
+
return;
|
|
22
|
+
const withTrailingNewline = content.endsWith('\n') ? content : `${content}\n`;
|
|
23
|
+
writeFileSync(gitignorePath, `${withTrailingNewline}${ENTRY}\n`);
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { buildHandoffMarkdown } from "./build-handoff-markdown.js";
|
|
4
|
+
import { ensureGitignoreExcludesHandoff } from "./ensure-gitignore.js";
|
|
5
|
+
import { DEFAULT_REPO_PATH_CACHE_PATH, resolveRepoPath } from "./repo-path-cache.js";
|
|
6
|
+
/**
|
|
7
|
+
* Exports open change-request Comments as a self-describing hand-off
|
|
8
|
+
* Markdown file into the target repo's local working tree, and ensures
|
|
9
|
+
* `.review-feedback/` is gitignored there. A no-op when there are no open
|
|
10
|
+
* change-requests — this is what makes it safe to call after every publish.
|
|
11
|
+
*/
|
|
12
|
+
export async function exportHandoff(options) {
|
|
13
|
+
const { bundlePath, comparison, round, comments, annotations, ask, repoPathCachePath } = options;
|
|
14
|
+
const hasOpenChangeRequest = comments.some((c) => c.kind === 'change-request' && c.status === 'open' && !c.resolved);
|
|
15
|
+
if (!hasOpenChangeRequest)
|
|
16
|
+
return { written: false };
|
|
17
|
+
if (!comparison.repository) {
|
|
18
|
+
throw new Error('comparison.repository is required to export a hand-off file');
|
|
19
|
+
}
|
|
20
|
+
const repoPath = await resolveRepoPath(comparison.repository, ask, repoPathCachePath ?? DEFAULT_REPO_PATH_CACHE_PATH);
|
|
21
|
+
const markdown = buildHandoffMarkdown({ bundlePath, comparison, round, comments, annotations });
|
|
22
|
+
const feedbackDir = join(repoPath, '.review-feedback');
|
|
23
|
+
mkdirSync(feedbackDir, { recursive: true });
|
|
24
|
+
const filePath = join(feedbackDir, `${comparison.number}-round${round}.md`);
|
|
25
|
+
writeFileSync(filePath, markdown);
|
|
26
|
+
ensureGitignoreExcludesHandoff(repoPath);
|
|
27
|
+
return { written: true, filePath, repoPath };
|
|
28
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
export const DEFAULT_REPO_PATH_CACHE_PATH = join(homedir(), '.claude', 'review-workspace-repo-paths.json');
|
|
5
|
+
function readCache(cachePath) {
|
|
6
|
+
if (!existsSync(cachePath))
|
|
7
|
+
return {};
|
|
8
|
+
return JSON.parse(readFileSync(cachePath, 'utf-8'));
|
|
9
|
+
}
|
|
10
|
+
function writeCache(cachePath, cache) {
|
|
11
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
12
|
+
writeFileSync(cachePath, JSON.stringify(cache, null, 2) + '\n');
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolves a target repo's local checkout path for `repoSlug`. Reuses a
|
|
16
|
+
* cached answer as long as it still exists on disk; asks (via `ask`) and
|
|
17
|
+
* caches the answer otherwise — this covers both "never asked before" and
|
|
18
|
+
* "the cached checkout was moved or deleted" in the same branch.
|
|
19
|
+
*/
|
|
20
|
+
export async function resolveRepoPath(repoSlug, ask, cachePath = DEFAULT_REPO_PATH_CACHE_PATH) {
|
|
21
|
+
const cache = readCache(cachePath);
|
|
22
|
+
const cached = cache[repoSlug];
|
|
23
|
+
if (cached && existsSync(cached))
|
|
24
|
+
return cached;
|
|
25
|
+
const answer = await ask(repoSlug);
|
|
26
|
+
writeCache(cachePath, { ...cache, [repoSlug]: answer });
|
|
27
|
+
return answer;
|
|
28
|
+
}
|
|
@@ -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();
|
package/dist/renderer/render.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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(
|
|
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. */
|