review-workspace 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Salih Sayer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # Review Workspace
2
+
3
+ A local, offline decision surface for reviewing a code Comparison. See
4
+ `docs/framework.md` for product rules, `CONTEXT.md` for the domain glossary,
5
+ and `docs/spec.md` for the v1 spec.
6
+
7
+ ## Reviewing an MR/PR
8
+
9
+ This repo (the engine: schema, validator, CLI, server, UI) never fetches a Comparison or writes a Review Document itself — that's the Generator's job, a separate Claude Code skill (see `skills/review-workspace/`). Paste an MR/PR URL to Claude Code with that skill available and ask it to review it — the skill scaffolds a bundle under `.bundles/`, generates + publishes the Review Document, then `serve`s it (see below).
10
+
11
+ To view an already-scaffolded bundle:
12
+
13
+ ```sh
14
+ npx review-workspace serve .bundles/<name> --port 4317
15
+ ```
16
+
17
+ 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.
18
+
19
+ ## Development
20
+
21
+ ```sh
22
+ pnpm install
23
+ pnpm test # vitest
24
+ pnpm typecheck # tsc --noEmit, doubling as lint (ponytail: no separate linter yet)
25
+ pnpm build:ui # builds ui/ and copies it to ui-dist/ so `serve` hosts the UI itself
26
+ ```
27
+
28
+ While developing the UI itself, run the two pieces separately instead — `node src/cli.ts serve .bundles/<name> --port 4317` for the API, and `cd ui && pnpm dev` for the UI with hot reload (its Vite dev server proxies `/api/*` to the port above).
29
+
30
+ Add fixtures under `fixtures/` — see `fixtures/README.md`. Never commit a real
31
+ Review Bundle (per ADR 0004); fixtures are synthetic only.
32
+
33
+ ## Releasing
34
+
35
+ Versioning and npm publishing are fully automated by
36
+ [semantic-release](https://semantic-release.org/) (`.releaserc.json`,
37
+ `.github/workflows/release.yml`) — every push to `main` computes the next
38
+ version from commit messages, publishes to npm, tags the release, and
39
+ updates `CHANGELOG.md`. There is no manual version bump.
40
+
41
+ This only works if commits follow [Conventional
42
+ Commits](https://www.conventionalcommits.org/): a `type` prefix tells
43
+ semantic-release what kind of release (if any) the commit deserves. A commit
44
+ that doesn't match a recognized type is ignored for versioning — safe, but it
45
+ means that change won't ship until a later commit does trigger a release.
46
+
47
+ ```
48
+ fix: correct hunk line-target off-by-one # patch release
49
+ feat: add file-level diff collapse # minor release
50
+ feat!: rename Target `kind` to `type` # major release (breaking)
51
+
52
+ BREAKING CHANGE: `target.kind` renamed to `target.type` in the Review Document schema.
53
+ ```
54
+
55
+ Other common types (`docs:`, `chore:`, `refactor:`, `test:`) don't trigger a
56
+ release on their own.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { validateBundle } from "./validate-bundle.js";
4
+ import { DEFAULT_LIMITS } from "../security/limits.js";
5
+ /**
6
+ * Validates a staged `review.next.json` and, only on success, atomically
7
+ * replaces `review.json` with it. On any failure `review.json` is left
8
+ * byte-for-byte untouched.
9
+ */
10
+ export function publishBundle(bundlePath, limits = DEFAULT_LIMITS) {
11
+ const nextPath = join(bundlePath, 'review.next.json');
12
+ if (!existsSync(nextPath)) {
13
+ return { ok: false, blockingReason: 'missing-next', message: 'review.next.json not found' };
14
+ }
15
+ const result = validateBundle(bundlePath, limits, 'review.next.json');
16
+ if (!result.valid) {
17
+ return { ok: false, blockingReason: result.blockingReason, message: result.message };
18
+ }
19
+ const reviewPath = join(bundlePath, 'review.json');
20
+ const tmpPath = join(bundlePath, `.review.json.tmp-${process.pid}-${Date.now()}`);
21
+ writeFileSync(tmpPath, readFileSync(nextPath, 'utf-8'));
22
+ renameSync(tmpPath, reviewPath);
23
+ return { ok: true };
24
+ }
@@ -0,0 +1,113 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { validateReviewDocumentSchema } from "../schema/validate.js";
3
+ import { resolveAssetPath } from "../security/asset-path.js";
4
+ import { DEFAULT_LIMITS } from "../security/limits.js";
5
+ import { readQuestions } from "../questions/questions-log.js";
6
+ function findFile(patch, path) {
7
+ return patch.files.find((f) => f.path === path);
8
+ }
9
+ function checkAssetPath(bundlePath, assetPath, limits) {
10
+ const resolution = resolveAssetPath(bundlePath, assetPath);
11
+ if (!resolution.ok) {
12
+ return resolution.reason === 'unsafe-path'
13
+ ? { kind: 'unsafe-asset-path', assetPath }
14
+ : { kind: 'disallowed-asset-type', assetPath };
15
+ }
16
+ if (!existsSync(resolution.absolutePath)) {
17
+ return { kind: 'missing-asset', assetPath };
18
+ }
19
+ const { size } = statSync(resolution.absolutePath);
20
+ if (size > limits.maxAssetBytes) {
21
+ return { kind: 'asset-too-large', assetPath, bytes: size };
22
+ }
23
+ return undefined;
24
+ }
25
+ function resolveTarget(target, patch) {
26
+ const file = findFile(patch, target.path);
27
+ if (target.type === 'file') {
28
+ if (!file)
29
+ return { kind: 'unresolved-target', targetType: 'file', path: target.path, detail: 'file not found in patch' };
30
+ return undefined;
31
+ }
32
+ if (target.type === 'binary') {
33
+ if (!file || !file.binary) {
34
+ return { kind: 'unresolved-target', targetType: 'binary', path: target.path, detail: 'no binary change found for file' };
35
+ }
36
+ return undefined;
37
+ }
38
+ if (target.type === 'hunk') {
39
+ if (!file || !file.hunks[target.hunkIndex]) {
40
+ return { kind: 'unresolved-target', targetType: 'hunk', path: target.path, detail: `hunk index ${target.hunkIndex} not found` };
41
+ }
42
+ return undefined;
43
+ }
44
+ // target.type === 'line'
45
+ const line = file?.hunks
46
+ .flatMap((h) => h.lines)
47
+ .find((l) => (target.side === 'base' ? l.oldLine === target.line : l.newLine === target.line));
48
+ if (!line) {
49
+ return {
50
+ kind: 'stale-line-target',
51
+ path: target.path,
52
+ side: target.side,
53
+ line: target.line,
54
+ expectedText: target.expectedText,
55
+ detail: 'no line found at that position',
56
+ };
57
+ }
58
+ if (line.text !== target.expectedText) {
59
+ return {
60
+ kind: 'stale-line-target',
61
+ path: target.path,
62
+ side: target.side,
63
+ line: target.line,
64
+ expectedText: target.expectedText,
65
+ detail: `expected ${JSON.stringify(target.expectedText)}, found ${JSON.stringify(line.text)}`,
66
+ };
67
+ }
68
+ return undefined;
69
+ }
70
+ export function collectDiagnostics(document, patch, bundlePath, limits = DEFAULT_LIMITS) {
71
+ const diagnostics = [];
72
+ const schemaResult = validateReviewDocumentSchema(document);
73
+ if (!schemaResult.valid) {
74
+ for (const err of schemaResult.errors ?? []) {
75
+ diagnostics.push({ kind: 'invalid-field', instancePath: err.instancePath, message: err.message ?? 'invalid field' });
76
+ }
77
+ }
78
+ for (const group of document.behavioralGroups ?? []) {
79
+ for (const target of group.targets ?? []) {
80
+ const diagnostic = resolveTarget(target, patch);
81
+ if (diagnostic)
82
+ diagnostics.push(diagnostic);
83
+ }
84
+ }
85
+ for (const annotation of document.annotations ?? []) {
86
+ const diagnostic = resolveTarget(annotation.target, patch);
87
+ if (diagnostic)
88
+ diagnostics.push(diagnostic);
89
+ for (const related of annotation.relatedTargets ?? []) {
90
+ const relatedDiagnostic = resolveTarget(related.target, patch);
91
+ if (relatedDiagnostic)
92
+ diagnostics.push(relatedDiagnostic);
93
+ }
94
+ }
95
+ for (const evidence of document.evidence ?? []) {
96
+ for (const assetPath of [evidence.assetPath, evidence.baseAssetPath]) {
97
+ if (!assetPath)
98
+ continue;
99
+ const diagnostic = checkAssetPath(bundlePath, assetPath, limits);
100
+ if (diagnostic)
101
+ diagnostics.push(diagnostic);
102
+ }
103
+ }
104
+ if (document.answers?.length) {
105
+ const questionIds = new Set(readQuestions(bundlePath).map((q) => q.id));
106
+ for (const answer of document.answers) {
107
+ if (!questionIds.has(answer.questionId)) {
108
+ diagnostics.push({ kind: 'dangling-answer', answerId: answer.id, questionId: answer.questionId });
109
+ }
110
+ }
111
+ }
112
+ return diagnostics;
113
+ }
@@ -0,0 +1,78 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parsePatch } from "../patch/parse.js";
4
+ import { PatchParseError } from "../patch/types.js";
5
+ import { collectDiagnostics } from "./resolve-diagnostics.js";
6
+ import { DEFAULT_LIMITS } from "../security/limits.js";
7
+ export const SUPPORTED_SCHEMA_VERSIONS = [1];
8
+ function hasComparisonIdentity(document) {
9
+ if (typeof document !== 'object' || document === null)
10
+ return false;
11
+ const comparison = document.comparison;
12
+ if (typeof comparison !== 'object' || comparison === null)
13
+ return false;
14
+ const { base, head } = comparison;
15
+ return typeof base === 'string' && base.length > 0 && typeof head === 'string' && head.length > 0;
16
+ }
17
+ export function validateBundle(bundlePath, limits = DEFAULT_LIMITS, documentFileName = 'review.json') {
18
+ let raw;
19
+ try {
20
+ raw = readFileSync(join(bundlePath, documentFileName), 'utf-8');
21
+ }
22
+ catch (err) {
23
+ return { valid: false, blockingReason: 'unparseable-json', message: err.message };
24
+ }
25
+ if (Buffer.byteLength(raw, 'utf-8') > limits.maxDocumentBytes) {
26
+ return {
27
+ valid: false,
28
+ blockingReason: 'document-too-large',
29
+ message: `${documentFileName} exceeds ${limits.maxDocumentBytes} bytes`,
30
+ };
31
+ }
32
+ let document;
33
+ try {
34
+ document = JSON.parse(raw);
35
+ }
36
+ catch (err) {
37
+ return { valid: false, blockingReason: 'unparseable-json', message: err.message };
38
+ }
39
+ const schemaVersion = document?.schemaVersion;
40
+ if (typeof schemaVersion !== 'number' || !SUPPORTED_SCHEMA_VERSIONS.includes(schemaVersion)) {
41
+ return {
42
+ valid: false,
43
+ blockingReason: 'unsupported-schema-version',
44
+ message: `schemaVersion ${String(schemaVersion)} is not supported (supported: ${SUPPORTED_SCHEMA_VERSIONS.join(', ')})`,
45
+ };
46
+ }
47
+ if (!hasComparisonIdentity(document)) {
48
+ return { valid: false, blockingReason: 'missing-comparison-identity', message: 'comparison.base/head are required' };
49
+ }
50
+ let rawPatch;
51
+ try {
52
+ rawPatch = readFileSync(join(bundlePath, 'changes.diff'), 'utf-8');
53
+ }
54
+ catch (err) {
55
+ return { valid: false, blockingReason: 'unusable-patch', message: err.message };
56
+ }
57
+ if (Buffer.byteLength(rawPatch, 'utf-8') > limits.maxPatchBytes) {
58
+ return { valid: false, blockingReason: 'patch-too-large', message: `changes.diff exceeds ${limits.maxPatchBytes} bytes` };
59
+ }
60
+ let patch;
61
+ try {
62
+ patch = parsePatch(rawPatch);
63
+ }
64
+ catch (err) {
65
+ const message = err instanceof PatchParseError || err instanceof Error ? err.message : String(err);
66
+ return { valid: false, blockingReason: 'unusable-patch', message };
67
+ }
68
+ const totalLines = patch.files.reduce((sum, f) => sum + f.hunks.reduce((s, h) => s + h.lines.length, 0), 0);
69
+ if (totalLines > limits.maxPatchLines) {
70
+ return {
71
+ valid: false,
72
+ blockingReason: 'too-many-patch-lines',
73
+ message: `patch has ${totalLines} lines, exceeding the limit of ${limits.maxPatchLines}`,
74
+ };
75
+ }
76
+ const diagnostics = collectDiagnostics(document, patch, bundlePath, limits);
77
+ return { valid: true, document, patch, diagnostics };
78
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ import { validateBundle } from "./bundle/validate-bundle.js";
3
+ import { publishBundle } from "./bundle/publish.js";
4
+ import { startReviewServer } from "./server/create-server.js";
5
+ const [, , command, bundlePath, ...rest] = process.argv;
6
+ function fail(message) {
7
+ console.error(message);
8
+ process.exit(1);
9
+ }
10
+ function flagValue(name) {
11
+ const index = rest.indexOf(name);
12
+ return index === -1 ? undefined : rest[index + 1];
13
+ }
14
+ if (!command || !bundlePath) {
15
+ fail('Usage: review-workspace <open|publish|serve> <bundlePath> [--port N]');
16
+ }
17
+ if (command === 'open') {
18
+ const result = validateBundle(bundlePath);
19
+ if (!result.valid) {
20
+ fail(`Cannot open bundle: ${result.blockingReason} — ${result.message}`);
21
+ }
22
+ console.log('Bundle is valid.');
23
+ if (result.diagnostics?.length) {
24
+ console.log(`${result.diagnostics.length} diagnostic(s) found.`);
25
+ }
26
+ }
27
+ else if (command === 'publish') {
28
+ const result = publishBundle(bundlePath);
29
+ if (!result.ok) {
30
+ fail(`Publish rejected: ${result.blockingReason} — ${result.message}`);
31
+ }
32
+ console.log('Published.');
33
+ }
34
+ else if (command === 'serve') {
35
+ const port = Number(flagValue('--port') ?? 4317);
36
+ const handle = await startReviewServer(bundlePath, { port });
37
+ console.log(JSON.stringify({ port: handle.port, writeToken: handle.writeToken, url: `http://127.0.0.1:${handle.port}` }));
38
+ }
39
+ else {
40
+ fail(`Unknown command: ${command}`);
41
+ }
@@ -0,0 +1,63 @@
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
+ /**
22
+ * A self-contained Markdown report — no server needed to view it afterward.
23
+ * Read-only with respect to the bundle: never mutates state.json or questions.jsonl.
24
+ */
25
+ export function buildReport(document, state) {
26
+ const lines = [];
27
+ lines.push('# Review Report');
28
+ lines.push('');
29
+ lines.push(`**Comparison:** ${document.comparison.base} → ${document.comparison.head}`);
30
+ lines.push(`**Decision:** ${state.decision}`);
31
+ lines.push('');
32
+ lines.push('## Behavioral Groups');
33
+ const groups = document.behavioralGroups ?? [];
34
+ if (groups.length === 0) {
35
+ lines.push('_None declared._');
36
+ }
37
+ else {
38
+ for (const group of [...groups].sort((a, b) => a.order - b.order)) {
39
+ const progress = state.groups[group.id];
40
+ lines.push(`- **${group.title}** — understood: ${progress?.understood ? 'yes' : 'no'}, verified: ${progress?.verified ? 'yes' : 'no'}`);
41
+ }
42
+ }
43
+ 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
+ lines.push('## Notes');
55
+ if (state.notes.length === 0) {
56
+ lines.push('_None._');
57
+ }
58
+ else {
59
+ for (const note of state.notes)
60
+ lines.push(`- ${note}`);
61
+ }
62
+ return lines.join('\n') + '\n';
63
+ }
@@ -0,0 +1,168 @@
1
+ import { PatchParseError } from "./types.js";
2
+ const GIT_FILE_HEADER = /^diff --git a\/(.+) b\/(.+)$/;
3
+ const OLD_FILE_MARKER = /^--- (.+)$/;
4
+ const NEW_FILE_MARKER = /^\+\+\+ (.+)$/;
5
+ const BINARY_MARKER = /^Binary files (?:a\/)?(.+) and (?:b\/)?(.+) differ$/;
6
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
7
+ function stripPrefix(path) {
8
+ if (path === '/dev/null')
9
+ return path;
10
+ if (path.startsWith('a/') || path.startsWith('b/'))
11
+ return path.slice(2);
12
+ return path;
13
+ }
14
+ /**
15
+ * Parses both git's extended unified diff format ("diff --git a/X b/Y" plus
16
+ * "--- "/"+++ " headers) and a bare POSIX unified diff (just "--- "/"+++ ",
17
+ * no "diff --git" line, no a/ b/ path prefixes) — real Generators and forge
18
+ * tooling (e.g. `glab mr diff`) emit either shape.
19
+ */
20
+ export function parsePatch(text) {
21
+ const lines = text.split('\n');
22
+ if (lines.length > 0 && lines[lines.length - 1] === '') {
23
+ lines.pop();
24
+ }
25
+ const files = [];
26
+ let currentFile = null;
27
+ let currentHunk = null;
28
+ let pendingGitPaths = null;
29
+ let pendingOldPath = null;
30
+ const closeHunk = () => {
31
+ if (!currentHunk)
32
+ return;
33
+ const expected = currentHunk.oldLines + currentHunk.newLines;
34
+ const seenOld = currentHunk.lines.filter((l) => l.kind !== 'add').length;
35
+ const seenNew = currentHunk.lines.filter((l) => l.kind !== 'remove').length;
36
+ if (seenOld !== currentHunk.oldLines || seenNew !== currentHunk.newLines) {
37
+ throw new PatchParseError(`Truncated hunk in ${currentFile?.path ?? 'unknown file'}: expected ${expected} old/new lines, saw ${seenOld}/${seenNew}`);
38
+ }
39
+ currentHunk = null;
40
+ };
41
+ for (const line of lines) {
42
+ const gitFileMatch = GIT_FILE_HEADER.exec(line);
43
+ if (gitFileMatch) {
44
+ closeHunk();
45
+ pendingGitPaths = { old: gitFileMatch[1], new: gitFileMatch[2] };
46
+ continue;
47
+ }
48
+ const binaryMatch = BINARY_MARKER.exec(line);
49
+ if (binaryMatch) {
50
+ closeHunk();
51
+ // A bare unified diff (no "diff --git") can still emit "--- "/"+++ " for a binary
52
+ // file before the "Binary files ... differ" line — that already created a
53
+ // (non-binary, hunk-less) file entry above, which this just converts in place
54
+ // rather than pushing a duplicate. Trust that already-open entry's path over the
55
+ // Binary marker's own paths: for a bare-format deletion the marker line reads
56
+ // "Binary files a/X and /dev/null differ", so deriving identity from its second
57
+ // capture group would wrongly produce "/dev/null" instead of the real path X.
58
+ if (!pendingGitPaths && currentFile !== null && currentFile.hunks.length === 0 && !currentFile.binary) {
59
+ currentFile.binary = true;
60
+ }
61
+ else {
62
+ const path = pendingGitPaths?.new ?? stripPrefix(binaryMatch[2]);
63
+ currentFile = {
64
+ path,
65
+ oldPath: pendingGitPaths?.old ?? stripPrefix(binaryMatch[1]),
66
+ binary: true,
67
+ hunks: [],
68
+ };
69
+ files.push(currentFile);
70
+ }
71
+ pendingGitPaths = null;
72
+ continue;
73
+ }
74
+ if (line.startsWith('index '))
75
+ continue;
76
+ // A real file header only ever appears between hunks, never inside one — so once a
77
+ // hunk is open and still expects more lines, a "--- "/"+++ "-prefixed line is content
78
+ // (e.g. a removed line whose text itself starts with "-- "), not a new file boundary.
79
+ if (!currentHunk) {
80
+ const oldMatch = OLD_FILE_MARKER.exec(line);
81
+ if (oldMatch) {
82
+ pendingOldPath = stripPrefix(oldMatch[1]);
83
+ continue;
84
+ }
85
+ const newMatch = NEW_FILE_MARKER.exec(line);
86
+ if (newMatch) {
87
+ closeHunk();
88
+ const newPath = stripPrefix(newMatch[1]);
89
+ currentFile = {
90
+ path: pendingGitPaths?.new ?? newPath,
91
+ oldPath: pendingGitPaths?.old ?? pendingOldPath ?? newPath,
92
+ binary: false,
93
+ hunks: [],
94
+ };
95
+ files.push(currentFile);
96
+ pendingGitPaths = null;
97
+ pendingOldPath = null;
98
+ continue;
99
+ }
100
+ }
101
+ if (!currentFile) {
102
+ if (line.trim() === '')
103
+ continue;
104
+ throw new PatchParseError(`Content encountered before any file header: ${JSON.stringify(line)}`);
105
+ }
106
+ const hunkMatch = HUNK_HEADER.exec(line);
107
+ if (hunkMatch) {
108
+ closeHunk();
109
+ currentHunk = {
110
+ oldStart: Number(hunkMatch[1]),
111
+ oldLines: hunkMatch[2] !== undefined ? Number(hunkMatch[2]) : 1,
112
+ newStart: Number(hunkMatch[3]),
113
+ newLines: hunkMatch[4] !== undefined ? Number(hunkMatch[4]) : 1,
114
+ lines: [],
115
+ };
116
+ currentFile.hunks.push(currentHunk);
117
+ continue;
118
+ }
119
+ if (currentHunk) {
120
+ const marker = line[0];
121
+ const text = line.slice(1);
122
+ let oldLine;
123
+ let newLine;
124
+ const priorOld = currentHunk.lines.filter((l) => l.kind !== 'add').length;
125
+ const priorNew = currentHunk.lines.filter((l) => l.kind !== 'remove').length;
126
+ if (marker === '+') {
127
+ newLine = currentHunk.newStart + priorNew;
128
+ currentHunk.lines.push({ kind: 'add', text, newLine });
129
+ }
130
+ else if (marker === '-') {
131
+ oldLine = currentHunk.oldStart + priorOld;
132
+ currentHunk.lines.push({ kind: 'remove', text, oldLine });
133
+ }
134
+ else if (marker === ' ' || line === '') {
135
+ oldLine = currentHunk.oldStart + priorOld;
136
+ newLine = currentHunk.newStart + priorNew;
137
+ currentHunk.lines.push({ kind: 'context', text: line === '' ? '' : text, oldLine, newLine });
138
+ }
139
+ else if (line === '\') {
140
+ continue;
141
+ }
142
+ else {
143
+ throw new PatchParseError(`Unrecognized hunk line prefix in ${currentFile.path}: ${JSON.stringify(line)}`);
144
+ }
145
+ // Self-close the moment declared counts are satisfied, rather than waiting for the
146
+ // next header line to trigger it — otherwise an immediately-following bare "--- "/
147
+ // "+++ " file header (no git "diff --git" line ahead of it) is mistaken for hunk
148
+ // content, since the ambiguity guard above only reopens header-detection once no
149
+ // hunk is open.
150
+ const seenOld = currentHunk.lines.filter((l) => l.kind !== 'add').length;
151
+ const seenNew = currentHunk.lines.filter((l) => l.kind !== 'remove').length;
152
+ if (seenOld === currentHunk.oldLines && seenNew === currentHunk.newLines) {
153
+ currentHunk = null;
154
+ }
155
+ continue;
156
+ }
157
+ // A hunk self-closes as soon as its declared line count is satisfied (see above), but a
158
+ // trailing "no newline" marker for the hunk's last line arrives as the very next line —
159
+ // after the self-close, so it must still be recognized here, not just while a hunk is open.
160
+ if (line === '\')
161
+ continue;
162
+ if (line.trim() !== '') {
163
+ throw new PatchParseError(`Unexpected content outside a hunk in ${currentFile.path}: ${JSON.stringify(line)}`);
164
+ }
165
+ }
166
+ closeHunk();
167
+ return { files };
168
+ }
@@ -0,0 +1,2 @@
1
+ export class PatchParseError extends Error {
2
+ }
@@ -0,0 +1,80 @@
1
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ function logPath(bundlePath) {
5
+ return join(bundlePath, 'questions.jsonl');
6
+ }
7
+ function readLog(bundlePath) {
8
+ const path = logPath(bundlePath);
9
+ if (!existsSync(path))
10
+ return [];
11
+ const raw = readFileSync(path, 'utf-8');
12
+ return raw
13
+ .split('\n')
14
+ .filter((line) => line.trim() !== '')
15
+ .map((line) => JSON.parse(line));
16
+ }
17
+ function appendEntry(bundlePath, entry) {
18
+ appendFileSync(logPath(bundlePath), JSON.stringify(entry) + '\n');
19
+ }
20
+ /** Raises a new, immutable Question. Never mutates or removes any existing log entry. */
21
+ export function raiseQuestion(bundlePath, body, target) {
22
+ const entry = { type: 'raised', id: randomUUID(), createdAt: new Date().toISOString(), body, target };
23
+ appendEntry(bundlePath, entry);
24
+ return { id: entry.id, createdAt: entry.createdAt, body: entry.body, target: entry.target, status: 'open' };
25
+ }
26
+ /**
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.
29
+ */
30
+ export function withdrawAndReplaceQuestion(bundlePath, questionId, replacementBody, replacementTarget) {
31
+ const replacement = {
32
+ type: 'raised',
33
+ id: randomUUID(),
34
+ createdAt: new Date().toISOString(),
35
+ body: replacementBody,
36
+ target: replacementTarget,
37
+ };
38
+ const withdrawal = {
39
+ type: 'withdrawn',
40
+ id: randomUUID(),
41
+ createdAt: new Date().toISOString(),
42
+ questionId,
43
+ replacementId: replacement.id,
44
+ };
45
+ appendEntry(bundlePath, withdrawal);
46
+ appendEntry(bundlePath, replacement);
47
+ return {
48
+ withdrawn: questionId,
49
+ replacement: {
50
+ id: replacement.id,
51
+ createdAt: replacement.createdAt,
52
+ body: replacement.body,
53
+ target: replacement.target,
54
+ status: 'open',
55
+ },
56
+ };
57
+ }
58
+ /** Reduces the append-only log into the current set of Questions. */
59
+ export function readQuestions(bundlePath) {
60
+ const questions = new Map();
61
+ for (const entry of readLog(bundlePath)) {
62
+ if (entry.type === 'raised') {
63
+ questions.set(entry.id, {
64
+ id: entry.id,
65
+ createdAt: entry.createdAt,
66
+ body: entry.body,
67
+ target: entry.target,
68
+ status: 'open',
69
+ });
70
+ }
71
+ else {
72
+ const question = questions.get(entry.questionId);
73
+ if (question) {
74
+ question.status = 'withdrawn';
75
+ question.supersededBy = entry.replacementId;
76
+ }
77
+ }
78
+ }
79
+ return [...questions.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
80
+ }
@@ -0,0 +1 @@
1
+ export {};