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 +21 -0
- package/README.md +56 -0
- package/dist/bundle/diagnostics.js +1 -0
- package/dist/bundle/publish.js +24 -0
- package/dist/bundle/resolve-diagnostics.js +113 -0
- package/dist/bundle/validate-bundle.js +78 -0
- package/dist/cli.js +41 -0
- package/dist/export/report.js +63 -0
- package/dist/patch/parse.js +168 -0
- package/dist/patch/types.js +2 -0
- package/dist/questions/questions-log.js +80 -0
- package/dist/questions/types.js +1 -0
- package/dist/renderer/render.js +170 -0
- package/dist/renderer/types.js +1 -0
- package/dist/review-state/review-state.js +15 -0
- package/dist/review-state/types.js +6 -0
- package/dist/schema/types.js +1 -0
- package/dist/schema/validate.js +14 -0
- package/dist/security/asset-path.js +15 -0
- package/dist/security/limits.js +6 -0
- package/dist/security/sanitize.js +11 -0
- package/dist/server/create-server.js +204 -0
- package/dist/server/watch-bundle.js +31 -0
- package/package.json +60 -0
- package/schemas/review-document.schema.json +161 -0
- package/ui-dist/_app/index-CA2emAD8.css +1 -0
- package/ui-dist/_app/index-D0xXf3fQ.js +118 -0
- package/ui-dist/index.html +13 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { ALLOWED_ASSET_EXTENSIONS } from "../security/asset-path.js";
|
|
3
|
+
/**
|
|
4
|
+
* The app never launches a Generator itself (per framework.md) — this only
|
|
5
|
+
* builds text for the Reviewer to copy and run themselves.
|
|
6
|
+
*/
|
|
7
|
+
export function buildGeneratorPrompt(bundlePath) {
|
|
8
|
+
return `Invoke the /review-workspace skill on this bundle: ${resolve(bundlePath)}`;
|
|
9
|
+
}
|
|
10
|
+
/** Both layouts derive from the same RenderedLine[] — by construction they can never diverge in content. */
|
|
11
|
+
export function toInlineRows(lines) {
|
|
12
|
+
return lines;
|
|
13
|
+
}
|
|
14
|
+
export function toSideBySideRows(lines) {
|
|
15
|
+
const rows = [];
|
|
16
|
+
let i = 0;
|
|
17
|
+
while (i < lines.length) {
|
|
18
|
+
if (lines[i].kind === 'context') {
|
|
19
|
+
rows.push({ left: lines[i], right: lines[i] });
|
|
20
|
+
i++;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const removes = [];
|
|
24
|
+
while (i < lines.length && lines[i].kind === 'remove') {
|
|
25
|
+
removes.push(lines[i]);
|
|
26
|
+
i++;
|
|
27
|
+
}
|
|
28
|
+
const adds = [];
|
|
29
|
+
while (i < lines.length && lines[i].kind === 'add') {
|
|
30
|
+
adds.push(lines[i]);
|
|
31
|
+
i++;
|
|
32
|
+
}
|
|
33
|
+
const max = Math.max(removes.length, adds.length);
|
|
34
|
+
for (let j = 0; j < max; j++) {
|
|
35
|
+
rows.push({ left: removes[j] ?? null, right: adds[j] ?? null });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return rows;
|
|
39
|
+
}
|
|
40
|
+
const OVERFLOW_THRESHOLD = 120;
|
|
41
|
+
function targetPath(target) {
|
|
42
|
+
return target.path;
|
|
43
|
+
}
|
|
44
|
+
function diagnosticPath(diagnostic) {
|
|
45
|
+
return 'path' in diagnostic ? diagnostic.path : undefined;
|
|
46
|
+
}
|
|
47
|
+
function diagnosticLine(diagnostic) {
|
|
48
|
+
return diagnostic.kind === 'stale-line-target' ? diagnostic.line : undefined;
|
|
49
|
+
}
|
|
50
|
+
function renderFile(patchFile, document, fileDiagnostics) {
|
|
51
|
+
const fileAnnotations = (document.annotations ?? []).filter((a) => targetPath(a.target) === patchFile.path);
|
|
52
|
+
const hunks = patchFile.hunks.map((hunk, hunkIndex) => ({
|
|
53
|
+
oldStart: hunk.oldStart,
|
|
54
|
+
oldLines: hunk.oldLines,
|
|
55
|
+
newStart: hunk.newStart,
|
|
56
|
+
newLines: hunk.newLines,
|
|
57
|
+
lines: hunk.lines.map((line, lineIndex) => {
|
|
58
|
+
const lineAnnotations = fileAnnotations.filter((a) => a.target.type === 'line' &&
|
|
59
|
+
((a.target.side === 'base' && a.target.line === line.oldLine) ||
|
|
60
|
+
(a.target.side === 'head' && a.target.line === line.newLine)));
|
|
61
|
+
const lineDiagnostics = fileDiagnostics.filter((d) => diagnosticLine(d) !== undefined && (diagnosticLine(d) === line.oldLine || diagnosticLine(d) === line.newLine));
|
|
62
|
+
return {
|
|
63
|
+
id: `${patchFile.path}#${hunkIndex}#${lineIndex}`,
|
|
64
|
+
kind: line.kind,
|
|
65
|
+
text: line.text,
|
|
66
|
+
oldLine: line.oldLine,
|
|
67
|
+
newLine: line.newLine,
|
|
68
|
+
overflowsInline: line.text.length > OVERFLOW_THRESHOLD,
|
|
69
|
+
annotations: lineAnnotations,
|
|
70
|
+
diagnostics: lineDiagnostics,
|
|
71
|
+
};
|
|
72
|
+
}),
|
|
73
|
+
}));
|
|
74
|
+
const attachedLineDiagnosticKeys = new Set(hunks.flatMap((h) => h.lines.flatMap((l) => l.diagnostics)));
|
|
75
|
+
const fileLevelDiagnostics = fileDiagnostics.filter((d) => !attachedLineDiagnosticKeys.has(d));
|
|
76
|
+
const imageEvidence = (document.evidence ?? [])
|
|
77
|
+
.filter((e) => e.assetPath && ALLOWED_ASSET_EXTENSIONS.some((ext) => e.assetPath.toLowerCase().endsWith(ext)))
|
|
78
|
+
.filter((e) => (e.targetIds ?? []).includes(patchFile.path) || fileAnnotations.some((a) => (e.targetIds ?? []).includes(a.id)))
|
|
79
|
+
.map((e) => ({
|
|
80
|
+
id: e.id,
|
|
81
|
+
assetPath: e.assetPath,
|
|
82
|
+
baseAssetPath: e.baseAssetPath,
|
|
83
|
+
description: e.description,
|
|
84
|
+
comparisonModes: e.baseAssetPath ? ['side-by-side', 'swipe', 'onion-skin', 'changed-pixel'] : [],
|
|
85
|
+
}));
|
|
86
|
+
const pipelineEvidence = (document.evidence ?? [])
|
|
87
|
+
.filter((e) => e.pipeline)
|
|
88
|
+
.filter((e) => (e.targetIds ?? []).includes(patchFile.path) || fileAnnotations.some((a) => (e.targetIds ?? []).includes(a.id)))
|
|
89
|
+
.map((e) => ({
|
|
90
|
+
id: e.id,
|
|
91
|
+
jobName: e.pipeline.jobName,
|
|
92
|
+
status: e.pipeline.status,
|
|
93
|
+
url: e.pipeline.url,
|
|
94
|
+
logExcerpt: e.pipeline.logExcerpt,
|
|
95
|
+
description: e.description,
|
|
96
|
+
}));
|
|
97
|
+
return {
|
|
98
|
+
path: patchFile.path,
|
|
99
|
+
oldPath: patchFile.oldPath,
|
|
100
|
+
binary: patchFile.binary,
|
|
101
|
+
hunks,
|
|
102
|
+
annotations: fileAnnotations,
|
|
103
|
+
imageEvidence,
|
|
104
|
+
pipelineEvidence,
|
|
105
|
+
diagnostics: fileLevelDiagnostics,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Builds a deterministic, framework-agnostic view model from a validated bundle.
|
|
110
|
+
* Ordering: files belonging to a Behavioral Group are ordered by that group's
|
|
111
|
+
* declared `order`, then by the group's own target order; files not covered by
|
|
112
|
+
* any group fall back to patch order, per framework.md's "missing optional
|
|
113
|
+
* interpretation falls back to the complete patch in patch order" rule.
|
|
114
|
+
* Dependency/risk ordering is deferred — the schema has no such fields yet to sort by.
|
|
115
|
+
*/
|
|
116
|
+
export function render(document, patch, diagnostics, bundlePath) {
|
|
117
|
+
const diagnosticsByPath = new Map();
|
|
118
|
+
for (const d of diagnostics) {
|
|
119
|
+
const path = diagnosticPath(d);
|
|
120
|
+
if (!path)
|
|
121
|
+
continue;
|
|
122
|
+
const list = diagnosticsByPath.get(path) ?? [];
|
|
123
|
+
list.push(d);
|
|
124
|
+
diagnosticsByPath.set(path, list);
|
|
125
|
+
}
|
|
126
|
+
const groups = [...(document.behavioralGroups ?? [])]
|
|
127
|
+
.sort((a, b) => a.order - b.order)
|
|
128
|
+
.map((g) => ({
|
|
129
|
+
id: g.id,
|
|
130
|
+
title: g.title,
|
|
131
|
+
description: g.description,
|
|
132
|
+
order: g.order,
|
|
133
|
+
risk: g.risk,
|
|
134
|
+
filePaths: [...new Set((g.targets ?? []).map(targetPath))],
|
|
135
|
+
}));
|
|
136
|
+
const orderedPaths = [];
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
for (const group of groups) {
|
|
139
|
+
for (const path of group.filePaths) {
|
|
140
|
+
if (!seen.has(path)) {
|
|
141
|
+
seen.add(path);
|
|
142
|
+
orderedPaths.push(path);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const patchFile of patch.files) {
|
|
147
|
+
if (!seen.has(patchFile.path)) {
|
|
148
|
+
seen.add(patchFile.path);
|
|
149
|
+
orderedPaths.push(patchFile.path);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const patchFileByPath = new Map(patch.files.map((f) => [f.path, f]));
|
|
153
|
+
const files = orderedPaths
|
|
154
|
+
.map((path) => patchFileByPath.get(path))
|
|
155
|
+
.filter((f) => f !== undefined)
|
|
156
|
+
.map((f) => renderFile(f, document, diagnosticsByPath.get(f.path) ?? []));
|
|
157
|
+
const attachedPaths = new Set(patch.files.map((f) => f.path));
|
|
158
|
+
const bundleLevelDiagnostics = diagnostics.filter((d) => {
|
|
159
|
+
const path = diagnosticPath(d);
|
|
160
|
+
return !path || !attachedPaths.has(path);
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
comparison: document.comparison,
|
|
164
|
+
groups,
|
|
165
|
+
files,
|
|
166
|
+
diagnostics: bundleLevelDiagnostics,
|
|
167
|
+
generatorPrompt: buildGeneratorPrompt(bundlePath),
|
|
168
|
+
answers: document.answers ?? [],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { EMPTY_REVIEW_STATE } from "./types.js";
|
|
4
|
+
function statePath(bundlePath) {
|
|
5
|
+
return join(bundlePath, 'state.json');
|
|
6
|
+
}
|
|
7
|
+
export function readReviewState(bundlePath) {
|
|
8
|
+
const path = statePath(bundlePath);
|
|
9
|
+
if (!existsSync(path))
|
|
10
|
+
return structuredClone(EMPTY_REVIEW_STATE);
|
|
11
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
12
|
+
}
|
|
13
|
+
export function writeReviewState(bundlePath, state) {
|
|
14
|
+
writeFileSync(statePath(bundlePath), JSON.stringify(state, null, 2));
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
2
|
+
import addFormats from 'ajv-formats';
|
|
3
|
+
import schema from '../../schemas/review-document.schema.json' with { type: 'json' };
|
|
4
|
+
const ajv = new Ajv2020({ allErrors: true });
|
|
5
|
+
addFormats(ajv);
|
|
6
|
+
const validateFn = ajv.compile(schema);
|
|
7
|
+
export function validateReviewDocumentSchema(input) {
|
|
8
|
+
const valid = validateFn(input);
|
|
9
|
+
if (!valid) {
|
|
10
|
+
return { valid: false, errors: validateFn.errors ?? [] };
|
|
11
|
+
}
|
|
12
|
+
return { valid: true, document: input };
|
|
13
|
+
}
|
|
14
|
+
export const CURRENT_SCHEMA_VERSION = 1;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { resolve, sep } from 'node:path';
|
|
2
|
+
export const ALLOWED_ASSET_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp'];
|
|
3
|
+
/** Resolves an evidence-declared relative path against the bundle's assets/ directory, refusing to leave it. */
|
|
4
|
+
export function resolveAssetPath(bundlePath, assetPath) {
|
|
5
|
+
const assetsDir = resolve(bundlePath, 'assets');
|
|
6
|
+
const resolved = resolve(assetsDir, assetPath);
|
|
7
|
+
if (resolved !== assetsDir && !resolved.startsWith(assetsDir + sep)) {
|
|
8
|
+
return { ok: false, reason: 'unsafe-path' };
|
|
9
|
+
}
|
|
10
|
+
const lowerPath = resolved.toLowerCase();
|
|
11
|
+
if (!ALLOWED_ASSET_EXTENSIONS.some((ext) => lowerPath.endsWith(ext))) {
|
|
12
|
+
return { ok: false, reason: 'disallowed-type' };
|
|
13
|
+
}
|
|
14
|
+
return { ok: true, absolutePath: resolved };
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const ESCAPES = {
|
|
2
|
+
'&': '&',
|
|
3
|
+
'<': '<',
|
|
4
|
+
'>': '>',
|
|
5
|
+
'"': '"',
|
|
6
|
+
"'": ''',
|
|
7
|
+
};
|
|
8
|
+
/** Disables raw HTML in Markdown-bearing text by escaping it to literal characters. */
|
|
9
|
+
export function escapeRawHtml(text) {
|
|
10
|
+
return text.replace(/[&<>"']/g, (char) => ESCAPES[char]);
|
|
11
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { createReadStream, existsSync, statSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { validateBundle } from "../bundle/validate-bundle.js";
|
|
6
|
+
import { publishBundle } from "../bundle/publish.js";
|
|
7
|
+
import { watchBundle } from "./watch-bundle.js";
|
|
8
|
+
import { raiseQuestion, readQuestions, withdrawAndReplaceQuestion } from "../questions/questions-log.js";
|
|
9
|
+
import { readReviewState, writeReviewState } from "../review-state/review-state.js";
|
|
10
|
+
import { render } from "../renderer/render.js";
|
|
11
|
+
import { buildConcernComments, buildReport } from "../export/report.js";
|
|
12
|
+
import { resolveAssetPath } from "../security/asset-path.js";
|
|
13
|
+
export function generateWriteToken() {
|
|
14
|
+
return randomBytes(24).toString('hex');
|
|
15
|
+
}
|
|
16
|
+
const WRITE_TOKEN_HEADER = 'x-write-token';
|
|
17
|
+
const WITHDRAW_PATH = /^\/questions\/([^/]+)\/withdraw$/;
|
|
18
|
+
const ASSET_CONTENT_TYPES = {
|
|
19
|
+
'.png': 'image/png',
|
|
20
|
+
'.jpg': 'image/jpeg',
|
|
21
|
+
'.jpeg': 'image/jpeg',
|
|
22
|
+
'.gif': 'image/gif',
|
|
23
|
+
'.webp': 'image/webp',
|
|
24
|
+
};
|
|
25
|
+
// `pnpm build:ui` copies the built Vite app here (sibling of src/) so a published
|
|
26
|
+
// package can serve the UI itself — `npx review-workspace serve` with no separate
|
|
27
|
+
// `cd ui && pnpm dev` step. Absent when running from source (dev uses Vite's own
|
|
28
|
+
// dev server + proxy instead), so every lookup below treats a missing dir as "not built".
|
|
29
|
+
const UI_DIST_DIR = join(import.meta.dirname, '..', '..', 'ui-dist');
|
|
30
|
+
const UI_CONTENT_TYPES = {
|
|
31
|
+
'.html': 'text/html',
|
|
32
|
+
'.js': 'text/javascript',
|
|
33
|
+
'.css': 'text/css',
|
|
34
|
+
'.svg': 'image/svg+xml',
|
|
35
|
+
'.json': 'application/json',
|
|
36
|
+
...ASSET_CONTENT_TYPES,
|
|
37
|
+
};
|
|
38
|
+
function serveUiStatic(pathname, res) {
|
|
39
|
+
if (!existsSync(UI_DIST_DIR))
|
|
40
|
+
return false;
|
|
41
|
+
// SPA fallback: any path without a recognized static-file extension is a client-side
|
|
42
|
+
// route (e.g. a deep link) — serve index.html and let the app's own router take over.
|
|
43
|
+
const hasExtension = /\.[a-zA-Z0-9]+$/.test(pathname);
|
|
44
|
+
const candidate = join(UI_DIST_DIR, hasExtension ? pathname : 'index.html');
|
|
45
|
+
const resolved = hasExtension && existsSync(candidate) && statSync(candidate).isFile() ? candidate : join(UI_DIST_DIR, 'index.html');
|
|
46
|
+
if (!existsSync(resolved))
|
|
47
|
+
return false;
|
|
48
|
+
const ext = resolved.slice(resolved.lastIndexOf('.')).toLowerCase();
|
|
49
|
+
res.writeHead(200, { 'content-type': UI_CONTENT_TYPES[ext] ?? 'application/octet-stream' });
|
|
50
|
+
createReadStream(resolved).pipe(res);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
function readJsonBody(req) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
let raw = '';
|
|
56
|
+
req.on('data', (chunk) => {
|
|
57
|
+
raw += chunk;
|
|
58
|
+
});
|
|
59
|
+
req.on('end', () => {
|
|
60
|
+
try {
|
|
61
|
+
resolve(raw.trim() === '' ? {} : JSON.parse(raw));
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
reject(err);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
req.on('error', reject);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export function startReviewServer(bundlePath, opts = {}) {
|
|
71
|
+
const writeToken = opts.writeToken ?? generateWriteToken();
|
|
72
|
+
// The served document is cached and only refreshed by the watcher below, so a
|
|
73
|
+
// publish must actually trigger re-validation for /document to ever change —
|
|
74
|
+
// this is what makes file watching load-bearing rather than a no-op wrapper
|
|
75
|
+
// around a stateless re-read-per-request design.
|
|
76
|
+
let latest = validateBundle(bundlePath);
|
|
77
|
+
const watcher = watchBundle(bundlePath);
|
|
78
|
+
watcher.emitter.on('change', () => {
|
|
79
|
+
latest = validateBundle(bundlePath);
|
|
80
|
+
});
|
|
81
|
+
const server = createServer((req, res) => {
|
|
82
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
83
|
+
// The dev-mode Vite proxy strips this same prefix before forwarding here; a bundled
|
|
84
|
+
// production build talks to this one server directly, so both shapes must resolve
|
|
85
|
+
// to the same routes below.
|
|
86
|
+
if (url.pathname.startsWith('/api/'))
|
|
87
|
+
url.pathname = url.pathname.slice('/api'.length);
|
|
88
|
+
const respond = (status, body) => {
|
|
89
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
90
|
+
res.end(JSON.stringify(body));
|
|
91
|
+
};
|
|
92
|
+
if (req.method === 'GET' && url.pathname === '/document') {
|
|
93
|
+
respond(200, latest);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (req.method === 'GET' && url.pathname === '/view') {
|
|
97
|
+
if (!latest.valid) {
|
|
98
|
+
respond(422, { error: 'bundle is not valid', blockingReason: latest.blockingReason });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
respond(200, render(latest.document, latest.patch, latest.diagnostics ?? [], bundlePath));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (req.method === 'GET' && url.pathname === '/questions') {
|
|
105
|
+
respond(200, readQuestions(bundlePath));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (req.method === 'GET' && url.pathname === '/state') {
|
|
109
|
+
respond(200, readReviewState(bundlePath));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (req.method === 'GET' && url.pathname === '/report') {
|
|
113
|
+
if (!latest.valid) {
|
|
114
|
+
respond(422, { error: 'bundle is not valid', blockingReason: latest.blockingReason });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
res.writeHead(200, { 'content-type': 'text/markdown' });
|
|
118
|
+
res.end(buildReport(latest.document, readReviewState(bundlePath)));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (req.method === 'GET' && url.pathname === '/concerns') {
|
|
122
|
+
respond(200, buildConcernComments(readReviewState(bundlePath)));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (req.method === 'GET' && url.pathname.startsWith('/assets/')) {
|
|
126
|
+
const assetPath = decodeURIComponent(url.pathname.slice('/assets/'.length));
|
|
127
|
+
const resolution = resolveAssetPath(bundlePath, assetPath);
|
|
128
|
+
if (!resolution.ok || !existsSync(resolution.absolutePath) || !statSync(resolution.absolutePath).isFile()) {
|
|
129
|
+
respond(404, { error: 'asset not found' });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const ext = assetPath.slice(assetPath.lastIndexOf('.')).toLowerCase();
|
|
133
|
+
res.writeHead(200, { 'content-type': ASSET_CONTENT_TYPES[ext] ?? 'application/octet-stream' });
|
|
134
|
+
createReadStream(resolution.absolutePath).pipe(res);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (req.method !== 'GET') {
|
|
138
|
+
if (req.headers[WRITE_TOKEN_HEADER] !== writeToken) {
|
|
139
|
+
respond(401, { error: 'invalid write token' });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (req.method === 'POST' && url.pathname === '/publish') {
|
|
144
|
+
const result = publishBundle(bundlePath);
|
|
145
|
+
respond(result.ok ? 200 : 422, result);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (req.method === 'POST' && url.pathname === '/questions') {
|
|
149
|
+
readJsonBody(req)
|
|
150
|
+
.then((body) => {
|
|
151
|
+
const { body: text, target } = body;
|
|
152
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
153
|
+
respond(400, { error: 'body is required' });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
respond(201, raiseQuestion(bundlePath, text, target));
|
|
157
|
+
})
|
|
158
|
+
.catch(() => respond(400, { error: 'invalid JSON body' }));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const withdrawMatch = WITHDRAW_PATH.exec(url.pathname);
|
|
162
|
+
if (req.method === 'POST' && withdrawMatch) {
|
|
163
|
+
readJsonBody(req)
|
|
164
|
+
.then((body) => {
|
|
165
|
+
const { body: text, target } = body;
|
|
166
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
167
|
+
respond(400, { error: 'body is required for the replacement Question' });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
respond(201, withdrawAndReplaceQuestion(bundlePath, withdrawMatch[1], text, target));
|
|
171
|
+
})
|
|
172
|
+
.catch(() => respond(400, { error: 'invalid JSON body' }));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (req.method === 'PUT' && url.pathname === '/state') {
|
|
176
|
+
readJsonBody(req)
|
|
177
|
+
.then((body) => {
|
|
178
|
+
writeReviewState(bundlePath, body);
|
|
179
|
+
respond(200, body);
|
|
180
|
+
})
|
|
181
|
+
.catch(() => respond(400, { error: 'invalid JSON body' }));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (req.method === 'GET' && serveUiStatic(url.pathname, res))
|
|
185
|
+
return;
|
|
186
|
+
respond(404, { error: 'not found' });
|
|
187
|
+
});
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
server.once('error', reject);
|
|
190
|
+
server.listen(opts.port ?? 0, '127.0.0.1', () => {
|
|
191
|
+
const address = server.address();
|
|
192
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
193
|
+
resolve({
|
|
194
|
+
server,
|
|
195
|
+
writeToken,
|
|
196
|
+
port,
|
|
197
|
+
close: () => new Promise((res) => {
|
|
198
|
+
watcher.stop();
|
|
199
|
+
server.close(() => res());
|
|
200
|
+
}),
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Polls review.json's mtime rather than using fs.watch — fs.watch's rename/change
|
|
6
|
+
* semantics are notoriously inconsistent across platforms for atomic-rename-based
|
|
7
|
+
* writes, and a bundle is a local, low-frequency-write directory where a short poll
|
|
8
|
+
* interval is indistinguishable from a native watch in practice.
|
|
9
|
+
*/
|
|
10
|
+
export function watchBundle(bundlePath, intervalMs = 100) {
|
|
11
|
+
const emitter = new EventEmitter();
|
|
12
|
+
const reviewPath = join(bundlePath, 'review.json');
|
|
13
|
+
let lastMtime = statOrNull(reviewPath);
|
|
14
|
+
const timer = setInterval(() => {
|
|
15
|
+
const mtime = statOrNull(reviewPath);
|
|
16
|
+
if (mtime !== lastMtime) {
|
|
17
|
+
lastMtime = mtime;
|
|
18
|
+
emitter.emit('change');
|
|
19
|
+
}
|
|
20
|
+
}, intervalMs);
|
|
21
|
+
timer.unref();
|
|
22
|
+
return { emitter, stop: () => clearInterval(timer) };
|
|
23
|
+
}
|
|
24
|
+
function statOrNull(path) {
|
|
25
|
+
try {
|
|
26
|
+
return statSync(path).mtimeMs;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "review-workspace",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A local, offline decision surface for reviewing a code Comparison — evidence-linked map of intent, behavior, risk, and observable effects.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Salih Sayer",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/sabasayer/review-workspace.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/sabasayer/review-workspace#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/sabasayer/review-workspace/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"code-review",
|
|
17
|
+
"cli",
|
|
18
|
+
"pull-request",
|
|
19
|
+
"merge-request",
|
|
20
|
+
"diff"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"bin": {
|
|
24
|
+
"review-workspace": "dist/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"schemas",
|
|
29
|
+
"ui-dist"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=24"
|
|
33
|
+
},
|
|
34
|
+
"packageManager": "pnpm@10.33.2",
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public",
|
|
37
|
+
"provenance": true
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"build": "tsc -p tsconfig.build.json",
|
|
43
|
+
"build:ui": "cd ui && pnpm install && pnpm build && rm -rf ../ui-dist && mv dist ../ui-dist",
|
|
44
|
+
"prepublishOnly": "pnpm test && pnpm typecheck && pnpm build && pnpm build:ui"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@semantic-release/changelog": "^7.0.0",
|
|
48
|
+
"@semantic-release/git": "^11.0.1",
|
|
49
|
+
"@semantic-release/github": "^12.0.9",
|
|
50
|
+
"@semantic-release/npm": "^13.1.5",
|
|
51
|
+
"@types/node": "^26.1.2",
|
|
52
|
+
"semantic-release": "^25.0.8",
|
|
53
|
+
"typescript": "^5.7.0",
|
|
54
|
+
"vitest": "^3.0.0"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"ajv": "^8.20.0",
|
|
58
|
+
"ajv-formats": "^3.0.1"
|
|
59
|
+
}
|
|
60
|
+
}
|