review-workspace 1.10.2 → 1.12.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/README.md CHANGED
@@ -30,7 +30,7 @@ To view an already-scaffolded bundle:
30
30
  npx review-workspace serve .bundles/<name> --port 4317
31
31
  ```
32
32
 
33
- Opens the UI at `http://127.0.0.1:4317` (prints a write token too — needed only to raise/answer Questions, not to view). One process, no separate dev server.
33
+ Opens the UI at `http://127.0.0.1:4317` (prints a write token too — needed only to raise/answer Questions, not to view). The token is saved in the bundle as `.write-token` and stays the same after a restart. Use `serve --rotate-token` to make a new one. One process, no separate dev server.
34
34
 
35
35
  ## Addressing review feedback in the target repo
36
36
 
@@ -0,0 +1,18 @@
1
+ const FIRST_VERSION_REQUIRING_NOTE_FIELDS = 2;
2
+ const VERDICTS_NEEDING_SUGGESTION = ['wrong', 'missing'];
3
+ export function checkAnnotationFields(document) {
4
+ if (document.schemaVersion < FIRST_VERSION_REQUIRING_NOTE_FIELDS)
5
+ return [];
6
+ const diagnostics = [];
7
+ for (const annotation of document.annotations ?? []) {
8
+ if (!annotation.verdict) {
9
+ diagnostics.push({ kind: 'missing-verdict', annotationId: annotation.id });
10
+ continue;
11
+ }
12
+ const needsSuggestion = VERDICTS_NEEDING_SUGGESTION.includes(annotation.verdict);
13
+ if (needsSuggestion && !annotation.suggestion?.trim()) {
14
+ diagnostics.push({ kind: 'missing-suggestion', annotationId: annotation.id, verdict: annotation.verdict });
15
+ }
16
+ }
17
+ return diagnostics;
18
+ }
@@ -1,8 +1,8 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { parsePatch } from "../patch/parse.js";
4
- import { carryForwardComment, readQuestions } from "../questions/questions-log.js";
5
- import { evaluateResolution } from "./resolution.js";
4
+ import { carryForwardComment, isOpenChangeRequest, readQuestions } from "../questions/questions-log.js";
5
+ import { evaluateResolution, isCodeComment } from "./resolution.js";
6
6
  import { incrementalTouchedPaths } from "./incremental-patch.js";
7
7
  export function createCaches() {
8
8
  return { chain: new Map(), patch: new Map(), reviewDocument: new Map() };
@@ -30,7 +30,7 @@ function readReviewDocument(bundlePath, caches) {
30
30
  return caches.reviewDocument.get(bundlePath);
31
31
  }
32
32
  function openChangeRequests(bundlePath) {
33
- return readQuestions(bundlePath).filter((c) => c.kind === 'change-request' && c.status === 'open' && !c.resolved);
33
+ return readQuestions(bundlePath).filter(isOpenChangeRequest);
34
34
  }
35
35
  /**
36
36
  * Shared shape behind every chain-walking function below: a round-1 bundle (no
@@ -68,6 +68,7 @@ export function computeCarriedResolutions(bundlePath, caches = createCaches()) {
68
68
  const currentPatch = readPatch(bundlePath, caches);
69
69
  return readQuestions(bundlePath)
70
70
  .filter((c) => carriedIds.has(c.id))
71
+ .filter(isCodeComment)
71
72
  .map((comment) => ({ comment, resolution: evaluateResolution(comment, previousPatch, currentPatch) }));
72
73
  });
73
74
  }
@@ -99,6 +100,8 @@ export function listComments(bundlePath, caches = createCaches()) {
99
100
  }
100
101
  function targetKey(target) {
101
102
  switch (target.type) {
103
+ case 'general':
104
+ return 'general';
102
105
  case 'file':
103
106
  return `file:${target.path}`;
104
107
  case 'binary':
@@ -128,7 +131,7 @@ export function collectCarriedReviewContent(bundlePath, ownDocument, caches = cr
128
131
  const ownTargetKeys = new Set((ownDocument.annotations ?? []).map((a) => targetKey(a.target)));
129
132
  const ownGroupIds = new Set((ownDocument.behavioralGroups ?? []).map((g) => g.id));
130
133
  return {
131
- annotations: previousAll.annotations.filter((a) => !touchedPaths.has(a.target.path) && !ownTargetKeys.has(targetKey(a.target))),
134
+ annotations: previousAll.annotations.filter((a) => a.target.type !== 'general' && !touchedPaths.has(a.target.path) && !ownTargetKeys.has(targetKey(a.target))),
132
135
  behavioralGroups: previousAll.behavioralGroups.filter((g) => !ownGroupIds.has(g.id) && !(g.targets ?? []).some((t) => touchedPaths.has(t.path))),
133
136
  };
134
137
  });
@@ -0,0 +1,33 @@
1
+ function describeDiagnostic(d) {
2
+ switch (d.kind) {
3
+ case 'stale-line-target':
4
+ return `Stale annotation: ${d.detail}`;
5
+ case 'invalid-field':
6
+ return `Invalid field (${d.instancePath}): ${d.message}`;
7
+ case 'unresolved-target':
8
+ return `Unresolved ${d.targetType} target: ${d.detail}`;
9
+ case 'missing-asset':
10
+ return `Missing asset: ${d.assetPath}`;
11
+ case 'unsafe-asset-path':
12
+ return `Unsafe asset path: ${d.assetPath}`;
13
+ case 'disallowed-asset-type':
14
+ return `Disallowed asset type: ${d.assetPath}`;
15
+ case 'asset-too-large':
16
+ return `Asset too large (${d.bytes} bytes): ${d.assetPath}`;
17
+ case 'image-without-comparison':
18
+ return `Changed image has no base and head asset: ${d.path}`;
19
+ case 'dangling-answer':
20
+ return `Answer ${d.answerId} references unknown Question ${d.questionId}`;
21
+ case 'unanchored-verification-gap':
22
+ return `Verification gap ${d.verificationId} has no concrete anchor: ${d.detail}`;
23
+ case 'missing-verdict':
24
+ return `Note ${d.annotationId} has no verdict. Set it to intended, missing, wrong, extra or unclear.`;
25
+ case 'missing-suggestion':
26
+ return `Note ${d.annotationId} has the verdict ${d.verdict} but no suggestion. Say what to change.`;
27
+ case 'invalid-proof-link':
28
+ return `Proof link in evidence ${d.evidenceId} is broken: ${d.detail}`;
29
+ }
30
+ }
31
+ export function formatDiagnostic(d) {
32
+ return `${d.kind}: ${describeDiagnostic(d)}`;
33
+ }
@@ -0,0 +1,34 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ export const LIVE_HEAD_FILE = 'live-head.json';
4
+ function parseLiveHead(value) {
5
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
6
+ return `${LIVE_HEAD_FILE} must be a JSON object`;
7
+ const { head, checkedAt } = value;
8
+ if (typeof head !== 'string' || head === '')
9
+ return 'head must be a non-empty string';
10
+ if (typeof checkedAt !== 'string' || Number.isNaN(Date.parse(checkedAt)))
11
+ return 'checkedAt must be an ISO date string';
12
+ return { head, checkedAt };
13
+ }
14
+ export function readLiveHead(bundlePath) {
15
+ const path = join(bundlePath, LIVE_HEAD_FILE);
16
+ if (!existsSync(path))
17
+ return { status: 'absent' };
18
+ let raw;
19
+ try {
20
+ raw = JSON.parse(readFileSync(path, 'utf-8'));
21
+ }
22
+ catch (err) {
23
+ return { status: 'invalid', message: `${LIVE_HEAD_FILE} is not valid JSON: ${err.message}` };
24
+ }
25
+ const parsed = parseLiveHead(raw);
26
+ return typeof parsed === 'string' ? { status: 'invalid', message: parsed } : { status: 'ok', liveHead: parsed };
27
+ }
28
+ export function bundleStaleness(comparisonHead, liveHead, round) {
29
+ return { stale: liveHead !== undefined && liveHead.head !== comparisonHead, round };
30
+ }
31
+ export function buildLiveHeadStatus(read, comparisonHead, round) {
32
+ const liveHead = read.status === 'ok' ? read.liveHead : undefined;
33
+ return { ...read, recordedHead: comparisonHead, ...bundleStaleness(comparisonHead, liveHead, round) };
34
+ }
@@ -0,0 +1,71 @@
1
+ import { ALLOWED_ASSET_EXTENSIONS } from "../security/asset-path.js";
2
+ function isImagePath(id) {
3
+ const lowerId = id.toLowerCase();
4
+ return ALLOWED_ASSET_EXTENSIONS.some((ext) => lowerId.endsWith(ext));
5
+ }
6
+ function hunkIndexOf(target, patch) {
7
+ if (target.type === 'hunk')
8
+ return target.hunkIndex;
9
+ if (target.type !== 'line')
10
+ return undefined;
11
+ const file = patch.files.find((f) => f.path === target.path);
12
+ const index = file?.hunks.findIndex((h) => h.lines.some((l) => (target.side === 'base' ? l.oldLine === target.line : l.newLine === target.line)));
13
+ return index === undefined || index < 0 ? undefined : index;
14
+ }
15
+ function codeRefFromAnnotation(annotation, patch) {
16
+ const { target } = annotation;
17
+ if (target.type === 'general' || target.type === 'binary')
18
+ return undefined;
19
+ const hunkIndex = hunkIndexOf(target, patch);
20
+ return { path: target.path, ...(hunkIndex === undefined ? {} : { hunkIndex }), annotationId: annotation.id };
21
+ }
22
+ function codeRefFromId(id, annotationsById, patch) {
23
+ const annotation = annotationsById.get(id);
24
+ if (annotation)
25
+ return codeRefFromAnnotation(annotation, patch);
26
+ const file = patch.files.find((f) => f.path === id);
27
+ return file && !file.binary ? { path: id } : undefined;
28
+ }
29
+ function isIdOfImageSide(id, annotationsById) {
30
+ const annotation = annotationsById.get(id);
31
+ return annotation?.target.type === 'binary';
32
+ }
33
+ function findLinksForEvidence(evidence, annotationsById, patch) {
34
+ const ids = evidence.targetIds ?? [];
35
+ const imageIds = ids.filter(isImagePath);
36
+ const otherIds = ids.filter((id) => !isImagePath(id) && !isIdOfImageSide(id, annotationsById));
37
+ if (imageIds.length === 0 || otherIds.length === 0)
38
+ return { links: [], diagnostics: [] };
39
+ if (!evidence.assetPath) {
40
+ const detail = 'the evidence has no assetPath, so there is no image to show';
41
+ return { links: [], diagnostics: [{ kind: 'invalid-proof-link', evidenceId: evidence.id, detail }] };
42
+ }
43
+ const diagnostics = [];
44
+ const changedImagePaths = new Set(patch.files.filter((f) => f.binary).map((f) => f.path));
45
+ const images = imageIds.filter((imagePath) => {
46
+ const exists = changedImagePaths.has(imagePath);
47
+ if (!exists) {
48
+ diagnostics.push({ kind: 'invalid-proof-link', evidenceId: evidence.id, detail: `image ${imagePath} is not a changed image in the patch` });
49
+ }
50
+ return exists;
51
+ });
52
+ const codeRefs = otherIds.flatMap((id) => codeRefFromId(id, annotationsById, patch) ?? []);
53
+ if (codeRefs.length === 0) {
54
+ const named = otherIds.join(', ');
55
+ diagnostics.push({
56
+ kind: 'invalid-proof-link',
57
+ evidenceId: evidence.id,
58
+ detail: `no code Target found for ${named} (use an Annotation id or a changed file path)`,
59
+ });
60
+ }
61
+ const links = images.flatMap((imagePath) => codeRefs.map((code) => ({ evidenceId: evidence.id, imagePath, code })));
62
+ return { links, diagnostics };
63
+ }
64
+ export function findProofLinks(document, patch) {
65
+ const annotationsById = new Map((document.annotations ?? []).map((a) => [a.id, a]));
66
+ const results = (document.evidence ?? []).map((e) => findLinksForEvidence(e, annotationsById, patch));
67
+ return {
68
+ links: results.flatMap((r) => r.links),
69
+ diagnostics: results.flatMap((r) => r.diagnostics),
70
+ };
71
+ }
@@ -24,8 +24,7 @@ export async function publishBundleAndExportHandoff(bundlePath, options) {
24
24
  round,
25
25
  comments: readQuestions(bundlePath),
26
26
  annotations: document.annotations ?? [],
27
- ask: options.ask,
28
- repoPathCachePath: options.repoPathCachePath,
27
+ repoPath: options.repoPath,
29
28
  });
30
29
  return { ...result, handoff };
31
30
  }
@@ -20,5 +20,5 @@ export function publishBundle(bundlePath, limits = DEFAULT_LIMITS) {
20
20
  const tmpPath = join(bundlePath, `.review.json.tmp-${process.pid}-${Date.now()}`);
21
21
  writeFileSync(tmpPath, readFileSync(nextPath, 'utf-8'));
22
22
  renameSync(tmpPath, reviewPath);
23
- return { ok: true };
23
+ return { ok: true, diagnostics: result.diagnostics };
24
24
  }
@@ -1,5 +1,9 @@
1
1
  import { resolveTarget } from "./resolve-diagnostics.js";
2
2
  import { incrementalHunks } from "./incremental-patch.js";
3
+ /** A general Comment has no place in the patch, so it has no Resolution. */
4
+ export function isCodeComment(comment) {
5
+ return comment.target?.type !== 'general';
6
+ }
3
7
  function targetHunkIndex(target, patch) {
4
8
  const file = patch.files.find((f) => f.path === target.path);
5
9
  if (!file)
@@ -1,8 +1,10 @@
1
1
  import { existsSync, statSync } from 'node:fs';
2
2
  import { validateReviewDocumentSchema } from "../schema/validate.js";
3
- import { resolveAssetPath } from "../security/asset-path.js";
3
+ import { ALLOWED_ASSET_EXTENSIONS, resolveAssetPath } from "../security/asset-path.js";
4
4
  import { DEFAULT_LIMITS } from "../security/limits.js";
5
5
  import { readQuestions } from "../questions/questions-log.js";
6
+ import { checkAnnotationFields } from "./annotation-fields.js";
7
+ import { findProofLinks } from "./proof-links.js";
6
8
  function findFile(patch, path) {
7
9
  return patch.files.find((f) => f.path === path);
8
10
  }
@@ -24,6 +26,8 @@ function checkAssetPath(bundlePath, assetPath, limits) {
24
26
  }
25
27
  /** Resolves a Target against a patch, returning the Diagnostic that explains why it's unresolved/stale, or undefined when it resolves cleanly. */
26
28
  export function resolveTarget(target, patch) {
29
+ if (target.type === 'general')
30
+ return undefined;
27
31
  const file = findFile(patch, target.path);
28
32
  if (target.type === 'file') {
29
33
  if (!file)
@@ -81,6 +85,34 @@ function isConcreteAnchor(id, annotationIds, evidenceById, filePaths) {
81
85
  return false;
82
86
  return (evidence.targetIds ?? []).some((innerId) => annotationIds.has(innerId) || filePaths.has(innerId));
83
87
  }
88
+ function intentVisualAssetPaths(document) {
89
+ return (document.intent?.visuals ?? []).flatMap((v) => (v.baseAssetPath ? [v.assetPath, v.baseAssetPath] : [v.assetPath]));
90
+ }
91
+ function isImagePath(path) {
92
+ const lowerPath = path.toLowerCase();
93
+ return ALLOWED_ASSET_EXTENSIONS.some((ext) => lowerPath.endsWith(ext));
94
+ }
95
+ function changedImagePaths(document, patch) {
96
+ const targets = [
97
+ ...(document.behavioralGroups ?? []).flatMap((g) => g.targets ?? []),
98
+ ...(document.annotations ?? []).map((a) => a.target),
99
+ ];
100
+ const paths = [
101
+ ...patch.files.filter((f) => f.binary).map((f) => f.path),
102
+ ...targets.flatMap((t) => (t.type === 'binary' ? [t.path] : [])),
103
+ ];
104
+ const addedPaths = new Set(patch.files.filter((f) => f.added).map((f) => f.path));
105
+ return [...new Set(paths.filter(isImagePath))].filter((path) => !addedPaths.has(path));
106
+ }
107
+ function findImagesWithoutComparison(document, patch) {
108
+ const pairedEvidence = (document.evidence ?? []).filter((e) => e.assetPath && e.baseAssetPath);
109
+ return changedImagePaths(document, patch)
110
+ .filter((path) => {
111
+ const annotationIds = (document.annotations ?? []).filter((a) => a.target.type !== 'general' && a.target.path === path).map((a) => a.id);
112
+ return !pairedEvidence.some((e) => (e.targetIds ?? []).some((id) => id === path || annotationIds.includes(id)));
113
+ })
114
+ .map((path) => ({ kind: 'image-without-comparison', path }));
115
+ }
84
116
  export function collectDiagnostics(document, patch, bundlePath, limits = DEFAULT_LIMITS) {
85
117
  const diagnostics = [];
86
118
  const schemaResult = validateReviewDocumentSchema(document);
@@ -115,6 +147,14 @@ export function collectDiagnostics(document, patch, bundlePath, limits = DEFAULT
115
147
  diagnostics.push(diagnostic);
116
148
  }
117
149
  }
150
+ for (const assetPath of intentVisualAssetPaths(document)) {
151
+ const diagnostic = checkAssetPath(bundlePath, assetPath, limits);
152
+ if (diagnostic)
153
+ diagnostics.push(diagnostic);
154
+ }
155
+ diagnostics.push(...checkAnnotationFields(document));
156
+ diagnostics.push(...findProofLinks(document, patch).diagnostics);
157
+ diagnostics.push(...findImagesWithoutComparison(document, patch));
118
158
  if (document.answers?.length) {
119
159
  const questionIds = new Set(readQuestions(bundlePath).map((q) => q.id));
120
160
  for (const answer of document.answers) {
@@ -1,8 +1,9 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, join, relative, resolve } from 'node:path';
3
+ import { carryForwardChain } from "./carry-forward.js";
3
4
  /**
4
5
  * A stable cross-round identity for one MR: repo slug + number, e.g.
5
- * "founda/application/xds-viewer-ui!367".
6
+ * "group/project!367".
6
7
  */
7
8
  export function mrKeyFor(comparison) {
8
9
  if (!comparison.repository || !comparison.number) {
@@ -47,7 +48,8 @@ export function checkRound(bundlePath, liveHead) {
47
48
  * Scaffolds a new sibling round bundle for an MR whose live head has moved
48
49
  * past what `bundlePath` recorded. Never mutates `bundlePath` (ADR 0002) —
49
50
  * only creates the new `<name>-r{N}` directory alongside it, with its own
50
- * Comparison, Unified Patch, and a `chain.json` linking it back.
51
+ * Comparison, Unified Patch, a `chain.json` linking it back, and a
52
+ * `questions.jsonl` holding the previous round's open change-requests.
51
53
  */
52
54
  export function scaffoldNextRound(bundlePath, liveHead, options) {
53
55
  const document = readReviewDocument(bundlePath);
@@ -73,5 +75,6 @@ export function scaffoldNextRound(bundlePath, liveHead, options) {
73
75
  comparison: { ...document.comparison, head: liveHead },
74
76
  }, null, 2));
75
77
  writeFileSync(join(newBundlePath, 'chain.json'), JSON.stringify(chain, null, 2));
78
+ carryForwardChain(newBundlePath);
76
79
  return { bundlePath: newBundlePath, chain };
77
80
  }
@@ -4,7 +4,7 @@ import { parsePatch } from "../patch/parse.js";
4
4
  import { PatchParseError } from "../patch/types.js";
5
5
  import { collectDiagnostics } from "./resolve-diagnostics.js";
6
6
  import { DEFAULT_LIMITS } from "../security/limits.js";
7
- export const SUPPORTED_SCHEMA_VERSIONS = [1];
7
+ export const SUPPORTED_SCHEMA_VERSIONS = [1, 2];
8
8
  function hasComparisonIdentity(document) {
9
9
  if (typeof document !== 'object' || document === null)
10
10
  return false;
@@ -0,0 +1,38 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ export const WATCHER_FILE = 'watcher.json';
4
+ function isCount(value) {
5
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0;
6
+ }
7
+ function parseWatcher(value) {
8
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
9
+ return `${WATCHER_FILE} must be a JSON object`;
10
+ const { enabled, cap, used, current = null, updatedAt } = value;
11
+ if (typeof enabled !== 'boolean')
12
+ return 'enabled must be true or false';
13
+ if (!isCount(cap))
14
+ return 'cap must be a whole number of 0 or more';
15
+ if (!isCount(used))
16
+ return 'used must be a whole number of 0 or more';
17
+ if (current !== null && typeof current !== 'string')
18
+ return 'current must be a Question id or null';
19
+ if (updatedAt === undefined)
20
+ return { enabled, cap, used, current };
21
+ if (typeof updatedAt !== 'string' || Number.isNaN(Date.parse(updatedAt)))
22
+ return 'updatedAt must be an ISO date-time string';
23
+ return { enabled, cap, used, current, updatedAt };
24
+ }
25
+ export function readWatcher(bundlePath) {
26
+ const path = join(bundlePath, WATCHER_FILE);
27
+ if (!existsSync(path))
28
+ return { status: 'absent' };
29
+ let raw;
30
+ try {
31
+ raw = JSON.parse(readFileSync(path, 'utf-8'));
32
+ }
33
+ catch (err) {
34
+ return { status: 'invalid', message: `${WATCHER_FILE} is not valid JSON: ${err.message}` };
35
+ }
36
+ const parsed = parseWatcher(raw);
37
+ return typeof parsed === 'string' ? { status: 'invalid', message: parsed } : { status: 'ok', watcher: parsed };
38
+ }
package/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from 'node:fs';
3
+ import { formatDiagnostic } from "./bundle/format-diagnostic.js";
3
4
  import { validateBundle } from "./bundle/validate-bundle.js";
4
5
  import { publishBundleAndExportHandoff } from "./bundle/publish-with-handoff.js";
5
6
  import { checkRound, scaffoldNextRound } from "./bundle/round.js";
6
- import { askForRepoPathOnStdin } from "./handoff/ask-repo-path.js";
7
7
  import { carryForwardChain } from "./bundle/carry-forward.js";
8
8
  import { startReviewServer } from "./server/create-server.js";
9
9
  const [, , command, bundlePath, ...rest] = process.argv;
@@ -16,7 +16,7 @@ function flagValue(name) {
16
16
  return index === -1 ? undefined : rest[index + 1];
17
17
  }
18
18
  if (!command || !bundlePath) {
19
- fail('Usage: review-workspace <open|publish|serve|round> <bundlePath> [--port N] [--live-head SHA --patch FILE]');
19
+ fail('Usage: review-workspace <open|publish|serve|round> <bundlePath> [--port N] [--rotate-token] [--repo PATH] [--live-head SHA --patch FILE]');
20
20
  }
21
21
  if (command === 'open') {
22
22
  carryForwardChain(bundlePath);
@@ -27,17 +27,29 @@ if (command === 'open') {
27
27
  console.log('Bundle is valid.');
28
28
  if (result.diagnostics?.length) {
29
29
  console.log(`${result.diagnostics.length} diagnostic(s) found.`);
30
+ for (const diagnostic of result.diagnostics) {
31
+ console.log(` ${formatDiagnostic(diagnostic)}`);
32
+ }
30
33
  }
31
34
  }
32
35
  else if (command === 'publish') {
33
- const result = await publishBundleAndExportHandoff(bundlePath, { ask: askForRepoPathOnStdin });
36
+ const result = await publishBundleAndExportHandoff(bundlePath, { repoPath: flagValue('--repo') });
34
37
  if (!result.ok) {
35
38
  fail(`Publish rejected: ${result.blockingReason} — ${result.message}`);
36
39
  }
37
40
  console.log('Published.');
41
+ for (const diagnostic of result.diagnostics ?? []) {
42
+ console.log(` ${formatDiagnostic(diagnostic)}`);
43
+ }
38
44
  if (result.handoff?.written) {
39
45
  console.log(`Hand-off file written: ${result.handoff.filePath}`);
40
46
  }
47
+ else if (result.handoff?.skipped === 'no-repo-path') {
48
+ console.log('Hand-off skipped: pass --repo <path> to export the open change-requests.');
49
+ }
50
+ else if (result.handoff?.skipped === 'not-a-git-repo') {
51
+ console.log('Hand-off skipped: the --repo path is not a git repository.');
52
+ }
41
53
  }
42
54
  else if (command === 'round') {
43
55
  const liveHead = flagValue('--live-head');
@@ -57,7 +69,7 @@ else if (command === 'round') {
57
69
  }
58
70
  else if (command === 'serve') {
59
71
  const port = Number(flagValue('--port') ?? 4317);
60
- const handle = await startReviewServer(bundlePath, { port });
72
+ const handle = await startReviewServer(bundlePath, { port, rotateToken: rest.includes('--rotate-token'), repoPath: flagValue('--repo') });
61
73
  console.log(JSON.stringify({ port: handle.port, writeToken: handle.writeToken, url: `http://127.0.0.1:${handle.port}` }));
62
74
  }
63
75
  else {
@@ -1,10 +1,16 @@
1
1
  import { resolve } from 'node:path';
2
- const GENERAL_GROUP = '(general — no Target)';
3
- function targetPath(target) {
4
- return target.path;
2
+ import { isOpenChangeRequest } from "../questions/questions-log.js";
3
+ const NO_TARGET_GROUP = '(general — no Target)';
4
+ const GENERAL_TARGET_GROUP = 'General';
5
+ function groupKey(target) {
6
+ if (!target)
7
+ return NO_TARGET_GROUP;
8
+ return target.type === 'general' ? GENERAL_TARGET_GROUP : target.path;
5
9
  }
6
10
  function targetKey(target) {
7
11
  switch (target.type) {
12
+ case 'general':
13
+ return 'general';
8
14
  case 'file':
9
15
  return `file:${target.path}`;
10
16
  case 'binary':
@@ -25,6 +31,8 @@ function targetsEqual(a, b) {
25
31
  }
26
32
  function describeTarget(target) {
27
33
  switch (target.type) {
34
+ case 'general':
35
+ return 'General';
28
36
  case 'file':
29
37
  return `File: ${target.path}`;
30
38
  case 'hunk':
@@ -36,25 +44,26 @@ function describeTarget(target) {
36
44
  }
37
45
  }
38
46
  function openChangeRequests(comments) {
39
- return comments.filter((c) => c.kind === 'change-request' && c.status === 'open' && !c.resolved);
47
+ return comments.filter(isOpenChangeRequest);
40
48
  }
41
49
  function groupByFile(comments) {
42
50
  const byFile = new Map();
43
51
  for (const comment of comments) {
44
- const key = comment.target ? targetPath(comment.target) : GENERAL_GROUP;
52
+ const key = groupKey(comment.target);
45
53
  const group = byFile.get(key) ?? [];
46
54
  group.push(comment);
47
55
  byFile.set(key, group);
48
56
  }
49
57
  return byFile;
50
58
  }
59
+ function groupRank(key) {
60
+ if (key === NO_TARGET_GROUP)
61
+ return 2;
62
+ return key === GENERAL_TARGET_GROUP ? 1 : 0;
63
+ }
51
64
  function orderedFileKeys(byFile) {
52
65
  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);
66
+ return groupRank(a) - groupRank(b) || a.localeCompare(b);
58
67
  });
59
68
  }
60
69
  function metadataBlock(input) {
@@ -95,7 +104,7 @@ export function buildHandoffMarkdown(input) {
95
104
  lines.push('');
96
105
  lines.push(comment.body);
97
106
  lines.push('');
98
- const relatedAnnotations = comment.target
107
+ const relatedAnnotations = comment.target && comment.target.type !== 'general'
99
108
  ? input.annotations.filter((a) => targetsEqual(a.target, comment.target))
100
109
  : [];
101
110
  for (const annotation of relatedAnnotations) {
@@ -0,0 +1,34 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ const ENTRY = '.review-feedback/';
5
+ function resolveExcludePath(repoPath) {
6
+ try {
7
+ const output = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], {
8
+ cwd: repoPath,
9
+ encoding: 'utf-8',
10
+ stdio: ['ignore', 'pipe', 'ignore'],
11
+ });
12
+ return resolve(repoPath, output.trim());
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ /**
19
+ * Lists `.review-feedback/` in the repo's local `.git/info/exclude`, so the
20
+ * hand-off file never shows up as a change and the tracked `.gitignore` is
21
+ * never edited. Returns false when `repoPath` is not a git repo.
22
+ */
23
+ export function ensureGitExcludesHandoff(repoPath) {
24
+ const excludePath = resolveExcludePath(repoPath);
25
+ if (!excludePath)
26
+ return false;
27
+ const content = existsSync(excludePath) ? readFileSync(excludePath, 'utf-8') : '';
28
+ if (content.split('\n').some((line) => line.trim() === ENTRY))
29
+ return true;
30
+ const withTrailingNewline = content === '' || content.endsWith('\n') ? content : `${content}\n`;
31
+ mkdirSync(dirname(excludePath), { recursive: true });
32
+ writeFileSync(excludePath, `${withTrailingNewline}${ENTRY}\n`);
33
+ return true;
34
+ }
@@ -1,28 +1,28 @@
1
1
  import { mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { isOpenChangeRequest } from "../questions/questions-log.js";
3
4
  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";
5
+ import { ensureGitExcludesHandoff } from "./ensure-git-exclude.js";
6
6
  /**
7
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.
8
+ * Markdown file into the target repo's local working tree, and lists
9
+ * `.review-feedback/` in that repo's `.git/info/exclude`. A no-op when there
10
+ * are no open change-requests — this is what makes it safe to call after
11
+ * every publish. Never prompts: without a usable repo path it skips.
11
12
  */
12
13
  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);
14
+ const { bundlePath, comparison, round, comments, annotations, repoPath } = options;
15
+ const hasOpenChangeRequest = comments.some(isOpenChangeRequest);
15
16
  if (!hasOpenChangeRequest)
16
17
  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);
18
+ if (!repoPath)
19
+ return { written: false, skipped: 'no-repo-path' };
20
+ if (!ensureGitExcludesHandoff(repoPath))
21
+ return { written: false, skipped: 'not-a-git-repo' };
21
22
  const markdown = buildHandoffMarkdown({ bundlePath, comparison, round, comments, annotations });
22
23
  const feedbackDir = join(repoPath, '.review-feedback');
23
24
  mkdirSync(feedbackDir, { recursive: true });
24
25
  const filePath = join(feedbackDir, `${comparison.number}-round${round}.md`);
25
26
  writeFileSync(filePath, markdown);
26
- ensureGitignoreExcludesHandoff(repoPath);
27
27
  return { written: true, filePath, repoPath };
28
28
  }
@@ -2,7 +2,9 @@ import { PatchParseError } from "./types.js";
2
2
  const GIT_FILE_HEADER = /^diff --git a\/(.+) b\/(.+)$/;
3
3
  const OLD_FILE_MARKER = /^--- (.+)$/;
4
4
  const NEW_FILE_MARKER = /^\+\+\+ (.+)$/;
5
+ const NO_BASE_PATH = '/dev/null';
5
6
  const BINARY_MARKER = /^Binary files (?:a\/)?(.+) and (?:b\/)?(.+) differ$/;
7
+ const EXTENDED_HEADER = /^(?:index |new file mode |deleted file mode |old mode |new mode |similarity index |rename from |rename to )/;
6
8
  const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
7
9
  function stripPrefix(path) {
8
10
  if (path === '/dev/null')
@@ -55,8 +57,11 @@ export function parsePatch(text) {
55
57
  // Binary marker's own paths: for a bare-format deletion the marker line reads
56
58
  // "Binary files a/X and /dev/null differ", so deriving identity from its second
57
59
  // capture group would wrongly produce "/dev/null" instead of the real path X.
60
+ const added = binaryMatch[1] === NO_BASE_PATH;
58
61
  if (!pendingGitPaths && currentFile !== null && currentFile.hunks.length === 0 && !currentFile.binary) {
59
62
  currentFile.binary = true;
63
+ if (added)
64
+ currentFile.added = true;
60
65
  }
61
66
  else {
62
67
  const path = pendingGitPaths?.new ?? stripPrefix(binaryMatch[2]);
@@ -65,13 +70,14 @@ export function parsePatch(text) {
65
70
  oldPath: pendingGitPaths?.old ?? stripPrefix(binaryMatch[1]),
66
71
  binary: true,
67
72
  hunks: [],
73
+ ...(added && { added }),
68
74
  };
69
75
  files.push(currentFile);
70
76
  }
71
77
  pendingGitPaths = null;
72
78
  continue;
73
79
  }
74
- if (line.startsWith('index '))
80
+ if (EXTENDED_HEADER.test(line))
75
81
  continue;
76
82
  // A real file header only ever appears between hunks, never inside one — so once a
77
83
  // hunk is open and still expects more lines, a "--- "/"+++ "-prefixed line is content